Перейти к содержанию

Обобщение алгоритмических стратегий#

Презентация Лекция 12

Ресурсы, расходуемые алгоритмом (вычислительные ресурсы)#

Вычислительные ресурсы — возможности, обеспечиваемые компонентами вычислительной системы, расходуемые (занимаемые) в процессе её работы.

Виды вычислительных ресурсов#

Ресурс Описание
Машинное (процессорное) время Время работы алгоритма для решения задачи
Оперативная память Объём памяти, необходимый алгоритму для выполнения задачи
Долговременная память Место на жёстком диске
Пропускная способность сети Пропускная способность передачи данных сети
Потребление энергии Энергия, поглощаемая и выделяемая во время вычислений

Алгоритмическая стратегия#

При создании компьютерной программы, предназначенной для решения какой-либо практической задачи, одним из важнейших этапов процесса создания является этап разработки алгоритма.

К настоящему времени разработано множество алгоритмических стратегий, определены ключевые принципы, лежащие в основе этих стратегий, что позволяет их применять как универсальный инструментарий для широкого класса задач.

Создано множество алгоритмов, которые могут служить фундаментом современного компьютерного программирования и которые можно использовать как строительные блоки при программировании.

При изложении дальнейшего материала основной акцент делается на понимании идей представляемой алгоритмической стратегии, а не на механическом рассмотрении работы того или иного алгоритма.

Очевидно, что для решения одной и той же задачи можно создавать разные алгоритмы в зависимости от выбора алгоритмической стратегии.

Конечно же, для того чтобы успешно использовать алгоритмы, созданные в соответствии с выбранной стратегией, недостаточно просто подготовить код программы.

Необходимо знать, как различные алгоритмы ведут себя в разных ситуациях. В конечном итоге именно эта информация определяет выбор стратегии, а также позволяет критически оценивать новые алгоритмы.

Алгоритмическая стратегия — это подход или метод, который используется для решения задачи в процессе разработки алгоритма. Стратегия выбора и применения методов зависит от типа задачи и требований к её решению.

Алгоритмические подходы#

  1. Методы «грубой силы».
  2. Жадные алгоритмы.
  3. Алгоритмы «разделяй и властвуй» (декомпозиции).
  4. Эвристические алгоритмы.
  5. Алгоритмы с возвратом.
  6. Муравьиные алгоритмы.
  7. Генетические алгоритмы.
  8. Алгоритмы численных приближений.
  9. Параллельные алгоритмы.

Методы «грубой силы» (полный перебор)#

Метод «грубой силы» (от англ. brute force), именуется также методом решения «в лоб». Метод грубой силы представляет собой прямой подход к решению задачи, обычно основанный непосредственно на формулировке задачи и определениях используемых ею концепций.

«Сила» в определении стратегии — сила компьютера, а не сила интеллекта, т.е. сила из пословицы: «Сила есть — ума не надо». Перефразировать определение данной стратегии можно проще: «Нечего думать, надо действовать!».

К методам «грубой силы» относят методы решения задачи путём перебора всех возможных вариантов. Сложность полного перебора зависит от размерности пространства всех возможных решений задачи. Если пространство решений очень велико, то полный перебор может не дать результатов в течение месяцев и даже лет.

Метод грубой силы используется для многих элементарных, но важных алгоритмических задач, таких как: вычисление суммы n чисел, поиск наибольшего элемента в списке, пузырьковая сортировка и т.д.

Ключевые тезисы#

  • Метод грубой силы (brute force) — решение "в лоб"
  • Основан на прямом подходе к решению задачи
  • Опирается на определения понятий, используемых в постановке задачи

Пример: задача возведения числа a в неотрицательную степень n#

Алгоритм решения "в лоб":

По определению: aⁿ = a · a · ... · a (n раз)

function pow(a, n)
    pow = a
    for i = 2 to n do
        pow = pow * a
    return pow

Асимптотическая оценка: T_Pow = O(n)

Задача поиска#

Поиск является одним из наиболее часто встречаемых действий в программировании.

В общем случае под «поиском» понимается процесс нахождения конкретной информации в ранее созданном множестве данных. Обычно данные представляют собой записи, каждая из которых имеет хотя бы один ключ. Ключ поиска — это поле записи, по значению которого происходит поиск. Ключи используются для отличия одних записей от других. Целью поиска является нахождение всех записей (если они есть) с данным значением ключа.

Существует множество различных алгоритмов поиска, которые принципиально зависят от способа организации данных. У каждого алгоритма поиска есть свои преимущества и недостатки. Поэтому важно выбрать тот алгоритм, который лучше всего подходит для решения конкретной задачи.

Представителем методов грубой силы является метод, который носит название последовательного или линейного поиска.

Идея этого метода заключается в следующем. Множество элементов просматривается последовательно в некотором порядке, гарантирующем, что будут просмотрены все элементы множества (например, слева направо). Если в ходе просмотра множества будет найден искомый элемент, просмотр прекращается с положительным результатом; если же будет просмотрено всё множество, а элемент не будет найден, алгоритм должен выдать отрицательный результат.

Описание функции на C++, описывающей процесс линейного поиска может иметь вид:

int LinearSearch(int *x, int k, int key)
{
    // key – ключ поиска (входной параметр)
    // x – целочисленный массив (входной параметр)
    // k – количество элементов в массиве (входной параметр)
    // i – вспомогательная переменная
    int i = 0; // устанавливается начальное значение индекса массива
    for (i = 0; i < k; i++) // цикл по изменению индекса
        if (x[i] == key) // проверка на совпадение с ключом, если совпал, то
            break; // выход из цикла (в этом случае значение i не достигло конечного k)
    return i < k ? i : -1; // вернуть найденный номер или значение -1, если нет такового
}

В качестве альтернативы продемонстрированному методу грубой силы можно предложить метод бинарного (двоичного, дихотомического) поиска. В нём поиск заданного элемента осуществляется на предварительно упорядоченном множестве данных. Поиск производится путём неоднократного деления этого множества на две части таким образом, что искомый элемент попадает в одну из этих частей.

Выводы по методу грубой силы#

Хотя методы грубой силы редко дают искусные или эффективные алгоритмы, его рассмотрение нельзя опустить, поскольку данный метод представляет собой важную стратегию разработки алгоритмов.

  1. Во-первых, в отличие от других стратегий, метод грубой силы применим к очень широкому диапазону задач. Похоже, это единственный подход, для которого существенно сложнее указать задачу, для решения которой он неприемлем.
  2. Во-вторых, для некоторых важных задач метод грубой силы даёт вполне рациональные алгоритмы.
  3. В-третьих, стоимость разработки более эффективного алгоритма может оказаться неприемлемой, если требуется решить всего несколько экземпляров задачи, а алгоритм, основанный на грубой силе, позволяет их решать за приемлемое время.
  4. В-четвёртых, даже будучи неэффективным в общем случае, метод грубой силы может оказаться полезен для решения небольших по размерам экземпляров задач.
  5. Наконец, алгоритм, основанный на грубой силе, может служить для важных теоретических и дидактических целей, например, мерилом для определения эффективности других алгоритмов для решения данной задачи.

Алгоритмы «разделяй и властвуй» (декомпозиции)#

Возможно, самым важным и наиболее широко применимым методом проектирования эффективных алгоритмов является метод, называемый методом "разделяй и властвуй" (или методом декомпозиции, или методом разбиения).

Этот метод предполагает такую декомпозицию (разбиение) задачи размера n на более мелкие задачи, что на основе решений этих более мелких задач можно легко получить решение исходной задачи и работают по следующей схеме:

  • Экземпляр задачи разбивается на несколько меньших экземпляров той же задачи, в идеале одинакового размера (но необязательно и не всегда).
  • Решаются меньшие экземпляры задачи (обычно рекурсивно, хотя иногда для небольших экземпляров применяется какой-либо другой алгоритм).
  • При необходимости решение исходной задачи находится путём комбинации решений меньших экземпляров.

На основе этого метода построены многие эффективные алгоритмы, хотя к некоторым задачам он может быть неприменим, а в других случаях давать худшие результаты, чем иное более простое алгоритмическое решение.

Если говорить о быстроте разработки, то ему вообще нет равных. Именно лёгкость разработки алгоритмов по методу декомпозиции обусловила огромную популярность этого метода. Метод декомпозиции дал кибернетике ряд очень важных и эффективных алгоритмов.

Структура алгоритмов декомпозиции#

  • Метод декомпозиции (decomposition method, метод "разделяй и властвуй" — "divide and conquer")
  • Структура алгоритмов, основанных на этом методе:
  • Задача разбивается на несколько меньших экземпляров той же задачи
  • Решаются сформированные меньшие экземпляры задачи (обычно рекурсивно)
  • При необходимости решение исходной задачи формируется как комбинация решений меньших экземпляров задачи

Problem = SubProblemA + SubProblemB

Возведение числа a в степень n (метод декомпозиции)#

Задача. Возвести число a в неотрицательную степень n.
Решение. Алгоритм на основе метода декомпозиции:

aⁿ = { a^⌊n/2⌋ · a^(n−⌊n/2⌋),  если n > 1
     { a,                        если n = 1
int pow_decomp(int a, int n)
{
    int k;
    if (n == 1)
        return a;
    k = n / 2;
    return pow_decomp(a, k) * pow_decomp(a, n - k);
}

Вычисление суммы чисел#

Задача. Вычислить сумму чисел a₀, a₁, ..., aₙ₋₁.

Алгоритм на основе метода грубой силы:#

function sum(a[0, n - 1])
    sum = 0
    for i = 0 to n - 1 do
        sum = sum + a[i]

T_Sum = O(n)

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

a₀ + a₁ + ··· + aₙ₋₁ = (a₀ + ··· + a_{⌊n/2⌋−1}) + (a_{⌊n/2⌋} + ... + aₙ₋₁)

Пример: 4 + 5 + 1 + 9 + 13 + 11 + 7 = (4 + 5 + 1) + (9 + 13 + 11 + 7) = ((4) + (5 + 1)) + ((9 + 13) + (11 + 7)) = 50

int sum(int *a, int l, int r)
{
    int k;
    if (l == r)
        return a[l];
    k = (r - l + 1) / 2;
    return sum(a, l, l + k - 1) + sum(a, l + k, r);
}

int main()
{
    s = sum(a, 0, N - 1);
}

Структура рекурсивных вызовов функции sum(0, 6):

          [0,6]
         /     \
      [0,2]   [3,6]
      /   \   /   \
   [0,0][1,2][3,4][5,6]
        / \ / \ / \
      [1,1][2,2][3,3][4,4][5,5][6,6]

4 + 5 + 1 + 9 + 13 + 11 + 7 = (4 + 5 + 1) + (9 + 13 + 11 + 7) = ((4) + (5 + 1)) + ((9 + 13) + (11 + 7)) = 50

Метод декомпозиции (Decomposition)#

В общем случае задача размера n делится на экземпляры задачи размера n/b, из которых a требуется решить (b > 1, a ≥ 0).

Время T(n) работы алгоритмы, основанного на методе декомпозиции, равно:

T(n) = aT(n/b) + f(n) ()

где f(n) — функция, учитывающая затраты времени на разделение задачи на экземпляры и комбинирование их решений.

Рекуррентное соотношение (*) — это обобщённое рекуррентное уравнение декомпозиции (general divide-and-conquer recurrence).

Мастер-теорема#

Теорема. Если в обобщённом рекуррентном уравнении декомпозиции функция f(n) = Θ(nᵈ), где d ≥ 0, то вычислительная сложность алгоритма равна:

T(n) = { Θ(nᵈ),           если a < bᵈ
       { Θ(nᵈ log n),      если a = bᵈ
       { Θ(n^(log_b a)),   если a > bᵈ

Анализ алгоритма суммирования n чисел#

  • b = 2 (интервал делим на 2 части)
  • a = 2 (обе части обрабатываем)
  • f(n) = 1 (трудоёмкость разделения интервала на 2 подмножества и слияние результатов (операция "+") выполняется за время O(1))
T(n) = 2T(n/2) + 1

Так как f(n) = 1 = n⁰, следовательно d = 0, тогда согласно теореме сложность алгоритма суммирования n чисел:

T(n) = θ(n^(log₂2)) = θ(n)

Примеры алгоритмов «разделяй и властвуй»#

Примеры алгоритмов, основанных на методе «разделяй и властвуй»:

  • Сортировка слиянием.
  • Быстрая сортировка.
  • Бинарный поиск.
  • Обход двоичного дерева.

Динамическое программирование#

Динамическое программирование (Dynamic programming) — метод решения задач (преимущественно оптимизационных) путём разбиения их на более простые подзадачи.

Решение задачи идёт от простых подзадач к сложным, периодически используя ответы для уже решённых подзадач (как правило, через рекуррентные соотношения).

Основная идея — запоминать решения встречающихся подзадач на случай, если та же подзадача встретится вновь.

Теория динамического программирования разработана Р. Беллманом в 1940-50-х годах.

Чтобы успешно решить задачу динамикой, нужно:#

  1. Состояние динамики: параметр(ы), однозначно задающие подзадачу.
  2. Значения начальных состояний.
  3. Переходы между состояниями: формула пересчёта.
  4. Порядок пересчёта.
  5. Положение ответа на задачу: иногда это сумма или, например, максимум из значений нескольких состояний.

Три порядка пересчёта:#

1) Прямой порядок: состояния последовательно пересчитываются исходя из уже посчитанных.

2) Обратный порядок: обновляются все состояния, зависящие от текущего состояния.

3) Ленивая динамика: рекурсивная мемоизированная функция пересчёта динамики. Это что-то вроде поиска в глубину по ациклическому графу состояний, где рёбра — это зависимости между ними.

Последовательность Фибоначчи#

0, 1, 1, 2, 3, 5, 8, 13, 21, 34, ...
F(n) = F(n − 1) + F(n − 2), при n > 1
F(0) = 0, F(1) = 1

Задача: вычислить n-й член последовательности Фибоначчи: F(n) = ?

Наивная рекурсивная реализация:#

function Fibo(n)
    if n <= 1 then
        return n
    return Fibo(n - 1) + Fibo(n - 2)

Дерево рекурсивных вызовов для Fibo(5):

            Fibo(5)
           /       \
        Fibo(4)   Fibo(3)
        /    \     /    \
    Fibo(3) Fibo(2) Fibo(2) Fibo(1)
    /   \   /   \   /   \
Fibo(2) Fibo(1) Fibo(1) Fibo(0) Fibo(1) Fibo(0)
/   \
Fibo(1) Fibo(0)

Проблема: некоторые элементы последовательности вычисляются повторно: Fibo(3), Fibo(2), ...

Реализация с динамическим программированием:#

function Fibo(n)
    F[0] = 0
    F[1] = 1
    for i = 2 to n do
        F[i] = F[i - 1] + F[i - 2]
    end for
    return F[n]
end function

В динамическом программировании используются таблицы, в которых сохраняются решения подзадач (жертвуем памятью ради времени).


Жадные алгоритмы (Greedy)#

Жадный алгоритм (Greedy algorithm) — алгоритм, заключающийся в принятии локально оптимального решения на каждом его этапе, допуская, что конечное решение также окажется оптимальным.

Рассмотрим небольшую "детскую" задачу. Допустим, что у нас есть монеты достоинством 50, 10, 5 копеек и 1 копейка и нужно вернуть сдачу в 63 копейки наименьшим количеством монет.

Почти не раздумывая, мы преобразуем эту величину в одну монету по 50 копеек, одну монету в 10 копеек и три монеты по одной копейке. Нам не только удалось быстро определить перечень монет нужного достоинства, но и, по сути, мы составили самый короткий список монет требуемого достоинства.

Алгоритм заключался в выборе монеты самого большого достоинства (50 копеек), но не больше 63 копеек, добавлении её в список сдачи и вычитании её стоимости из 63 (получается 13 копеек). Затем снова выбираем монету самого большого достоинства, но не больше остатка (13 копеек): этой монетой опять оказывается монета в 10 копеек. Эту монету мы опять добавляем в список сдачи, вычитаем её стоимость из остатка и т.д.

Обратите внимание, что алгоритм для определения сдачи обеспечивает в целом оптимальное решение лишь вследствие особых свойств монет. Если бы у нас были монеты достоинством 1 копейка, 5 и 11 копеек и нужно было бы дать сдачу 15 копеек, то "жадный" алгоритм выбрал бы сначала монету достоинством 11 копеек, а затем четыре монеты по одной копейке, т.е. всего пять монет. Однако в данном случае можно было бы обойтись тремя монетами по 5 копеек.

Из примера следует, что не каждый "жадный" алгоритм позволяет получить оптимальный результат в целом. Как нередко бывает в жизни, "жадная стратегия" подчас обеспечивает лишь сиюминутную выгоду, в то время как в целом результат может оказаться неблагоприятным.

Ключевые тезисы#

  • "Жадный" алгоритм (Greedy algorithms) — алгоритм, принимающий на каждом шаге локально-оптимальное решение
  • Предполагается, что конечное решение окажется оптимальным
  • Примеры "жадных" алгоритмов:
  • алгоритм Дейкстры
  • алгоритм Ближайшего соседа

Задача о размене#

Задача. Имеется неограниченное количество монет номиналом (достоинством) a₁ < a₂ < ... < aₙ. Требуется выдать сумму S наименьшим числом монет.

Пример. Имеются монеты достоинством 1, 2, 5 и 10 рублей. Выдать сумму S = 27 рублей.

"Жадное" решение (алгоритм): 2 монеты по 10 руб., 1 по 5, 1 по 2.

На каждом шаге берётся наибольшее возможное количество монет достоинства aₙ (от большего к меньшему).

Контрпример (когда жадный алгоритм не оптимален):

  • Монеты: 1, 2, 5 и 7 рублей. S = 27 рублей.
  • Решение жадным алгоритмом: 3 по 7, 3 по 1 = 6 монет
  • Оптимальное решение: 2 по 7, 2 по 5 = 4 монеты

Реализация жадного алгоритма на C++:#

void Money(int S, int *x, int *m, int n)
// S — величина сдачи — входной параметр
// x — указатель на массив, в котором хранятся достоинства разменных монет — входной параметр
// m — указатель на массив, в котором будут храниться количества монет каждого достоинства — выходной параметр
// n — количество монет-номиналов — входной параметр
{
    int i = 0; // начальное значение номера монеты
    while (i < n) // пока монеты не будут исчерпаны
    {
        m[i] = S / x[i]; // вычислить кол-во монет x[i] — это целочисленная операция!!!
        S = S - x[i] * m[i]; // оставшаяся сдача
        i++; // изменить номер
    }
}

Фрагмент кода с вызовом функции:

int a[4] = {50, 10, 5, 1};
int k[4] = {0};
Money(63, a, k, 4);


Поиск с возвратом (Backtracking)#

Поиск с возвратом (backtracking) — общий метод нахождения решений задачи, в которой требуется полный перебор всех возможных вариантов в некотором множестве M. Как правило, позволяет решать задачи, в которых ставятся вопросы типа: «Перечислите все возможные варианты …», «Сколько существует способов …», «Есть ли способ …», «Существует ли объект…» и т.п.

Термин backtracking был введён в 1950 году американским математиком Дерриком Генри Лемером (D.H. Lehmer).

Незначительные модификации метода поиска с возвратом, связанные с представлением данных или особенностями реализации, имеют и иные названия: метод проб и ошибок, метод ветвей и границ, поиск в глубину, и т.д.

Решение задачи методом поиска с возвратом сводится к последовательному расширению частичного решения. Если на очередном шаге такое расширение провести не удаётся, то возвращаются к более короткому частичному решению и продолжают поиск дальше. Данный алгоритм позволяет найти все решения поставленной задачи, если они существуют. Для ускорения метода стараются вычисления организовать таким образом, чтобы как можно раньше выявлять заведомо неподходящие варианты. Зачастую это позволяет значительно уменьшить время нахождения решения.

Метод поиска с возвратом является универсальным. Метод поиска с возвратом позволяет решать множество переборных задач. Например, с помощью него можно получить все подмножества, размещения, перестановки, сочетания данного множества M.

Недостатки#

Достаточно легко проектировать и программировать алгоритмы решения задач с использованием этого метода. Однако время нахождения решения может быть очень велико даже при небольших размерностях задачи (количестве исходных данных), причём настолько велико (может составлять годы или даже века), что о практическом применении не может быть и речи.

Ключевые тезисы#

  • Поиск с возвратом (Backtracking) — метод решения задач, в которых необходим полный перебор всех возможных вариантов в некотором множестве M
  • "Построить все возможные варианты ...", "Сколько существует способов ...", "Есть ли способ ..."
  • Термин Backtrack введён в 1950 г. D.H. Lehmer
  • Примеры задач:
  • Задача коммивояжёра
  • Подбор пароля
  • Задача о восьми ферзях
  • Задача о ранце

Пример: поиск выхода из лабиринта#

Лабиринт задаётся матрицей, где:
- 1 — стена
- 0 — проход
- Разрешено ходить влево, вправо, вверх и вниз

6 9       ← 6 строк, 9 столбцов
1 1       ← Старт: строка 1, столбец 1
1 1 1 1 1 1 1 1 1
1 0 0 0 0 0 1 1 1
1 0 0 1 1 0 0 0 1
1 1 0 0 0 0 1 0 1
1 0 0 1 0 1 1 0 1
1 1 1 1 0 1 1 1 1
int main(int argc, char **argv)
{
    if (argc < 2) {
        fprintf(stderr, "usage: maze <maze-file>\n");
        exit(1);
    }
    load_maze(argv[1]);
    print_maze();
    backtrack(startrow, startcol);
    printf("Exit not found\n");
    return 0;
}

void backtrack(int row, int col)
{
    int drow[4] = {-1, 0, 1, 0};
    int dcol[4] = {0, 1, 0, -1};
    int nextrow, nextcol, i;

    maze[row][col] = 2;           // Встали на новую позицию
    if (row == 0 || col == 0 ||
        row == (nrows - 1) || col == (ncols - 1)) {
        print_maze();             // Нашли выход
    }

    for (i = 0; i < 4; i++) {
        nextrow = row + drow[i];
        nextcol = col + dcol[i];
        if (maze[nextrow][nextcol] == 0)  // Проход есть?
            backtrack(nextrow, nextcol);
    }
    maze[row * ncols + col] = 0;
}

Пример результата (путь обозначен цифрой 2):

1 1 1 1 1 1 1 1 1
1 2 2 2 2 2 1 1 1
1 0 0 1 1 2 0 0 1
1 1 0 0 2 2 1 0 1
1 0 0 1 2 1 1 0 1
1 1 1 1 2 1 1 1 1

Задача о ходе коня#

Пример. Дана доска n×n, содержащая n² полей. Конь, который ходит согласно шахматным правилам, помещается на поле с начальными координатами x₀, y₀. Нужно покрыть всю доску ходами коня, т.е. вычислить обход доски, если он существует, из n²–1 ходов, такой, что каждое поле посещается ровно один раз.

Решение. Очевидно, что задачу покрытия n² полей можно свести к более простой: или вычислить очередной ход, или установить, что он невозможен. Алгоритм на каждом шаге предполагает проверку возможности сделать ход в каждом из 8 допустимых направлений.

Схематически процедура выполнения очередного хода средствами языка может быть представлена следующим образом:

procedure попытка следующего хода;
begin инициация выборки ходов;
  repeat выбор следующего возможного хода из списка очередных ходов;
    if он приемлем then begin запись хода;
      if доска не заполнена then
        begin попытка следующего хода
          if неудача then стирание предыдущего хода – возврат назад
        end
      end
    until (ход был удачным) ∨ (нет других возможных ходов)
end

Задача о восьми ферзях#

Классическая формулировка. Расставить на стандартной 64-клеточной шахматной доске 8 ферзей (королев) так, чтобы ни один из них не находился под боем другого.

Альтернативная формулировка. Заполнить матрицу размером 8×8 нулями и единицами таким образом, чтобы сумма всех элементов матрицы была равна 8, при этом сумма элементов ни в одном столбце, строке или диагональном ряде матрицы не превышала единицы.

Задача впервые была решена в 1850 г. Карлом Фридрихом Гаубом (Carl Friedrich Gaub). Число возможных решений на 64-клеточной доске: 92.

Реализация (C++):#

enum { N = 8 };
int board[N][N];

int main()
{
    int i, j;
    for (i = 0; i < N; i++)
        for (j = 0; j < N; j++)
            board[i][j] = 0;
    backtrack(0);
    return 0;
}

void backtrack(int row)
{
    int col;
    if (row >= N) {
        print_board();
    }
    for (col = 0; col < N; col++) {
        if (is_correct_order(row, col)) {
            board[row][col] = 1;    // Ставим ферзя
            backtrack(row + 1);
            board[row][col] = 0;    // Откат
        }
    }
}

int is_correct_order(int row, int col)
{
    int i, j;
    if (row == 0)
        return 1;
    /* Проверка позиций сверху */
    for (i = row - 1; i >= 0; i--) {
        if (board[i][col] != 0)
            return 0;
    }
    /* Проверить левую и правую диагонали... */
    return 1;
}

Пример результата (одно из 92 решений):

1 0 0 0 0 0 0 0
0 0 0 0 1 0 0 0
0 0 0 0 0 0 0 1
0 0 0 0 0 1 0 0
0 0 1 0 0 0 0 0
0 0 0 0 0 0 1 0
0 1 0 0 0 0 0 0
0 0 0 1 0 0 0 0

Метод ветвей и границ (Branch and Bound)#

Метод ветвей и границ (англ. branch and bound) — общий алгоритмический метод для нахождения оптимальных решений различных задач оптимизации, особенно дискретной и комбинаторной оптимизации.

Для метода ветвей и границ необходимы две процедуры:#

1. Ветвление. Состоит в разбиении множества допустимых значений переменной на подобласти (подмножества) меньших размеров. Полученные подобласти образуют дерево, называемое деревом поиска или деревом ветвей и границ.

2. Нахождение оценок (границ). Заключается в поиске верхних и нижних границ для решения задачи на подобласти допустимых значений переменной.

В основе метода ветвей и границ лежит идея: если нижняя граница значений функции на подобласти дерева поиска больше, чем верхняя граница на какой-либо ранее просмотренной подобласти, то подобласть может быть исключена из дальнейшего рассмотрения (правило отсева).

Метод используется для решения некоторых NP-полных задач, в том числе задачи коммивояжёра и задачи о ранце.

Задача о ранце (метод ветвей и границ)#

Дано n предметов весом w₁, ..., wₙ и ценой v₁, ..., vₙ, а также рюкзак, выдерживающий вес W. Требуется найти подмножество предметов, которое можно разместить в рюкзаке, и которое имеет при этом максимальную стоимость.

Оказывается удобным упорядочить предметы в убывающем порядке по их удельной цене (отношению цены к весу):

v₁/w₁ ≥ v₂/w₂ ≥ ··· ≥ vₙ/wₙ

Простым способом вычисления верхней границы ub является добавление к общей стоимости уже выбранных элементов v произведения оставшейся ёмкости рюкзака W − w и наибольшего значения удельной стоимости среди оставшихся элементов:

ub = v + (W − w)(v_{i+1}/w_{i+1})

Пример с предметами:

Предмет Вес Стоимость Удельная стоимость
1 4 40 10
2 7 42 6
3 5 25 5
4 3 12 4

Оптимальное решение методом ветвей и границ: подмножество {1,3} со стоимостью 65.


Численные алгоритмы#

Вычислительные (численные) методы — это методы решения математических задач, в которых предполагается как представление исходных данных, так и результатов решения в виде чисел.

Примерами применения численных методов являются:
- Решение систем линейных и нелинейных уравнений.
- Решение задач интерполирования.
- Приближённое вычисление функций.
- Численное решение дифференциальных уравнений.
- Решение задач оптимизации.
- И др.

Численные методы являются одним из мощных математических средств решения задач, и занимают особое место в курсе «Математика».

По большому счёту численные вычисления лежат в основе большого числа разнообразных программ. Так большого объёма вычислений с обработкой многочленов и матриц требует компьютерная графика. Эти вычисления обычно делаются для каждой точки экрана, поэтому даже незначительные улучшения алгоритмов могут привести к заметному ускорению.

Помимо приведённых примеров применения численных методов следует отметить ещё одно — вычисление тригонометрических функций. Чего не осознают многие программисты — это то, что стандартные процедуры вычисления значений тригонометрических функций типа синуса или косинуса используют их представление степенными рядами, а начальные отрезки таких рядов представляют собой многочлены и вычисление значений тригонометрических функций сводится к вычислению значений многочленов.

Пример 1. Квадратный корень по формуле Ньютона#

Разработать алгоритм для вычисления приближённого значения квадратного корня из числа N по формуле Ньютона:

x_{i+1} = 0.5 · (x_i + N/x_i), где (i = 0, 1, 2, ...)

Начальное приближение корня задано и равно x₀.

Так как речь идёт о приближённом значении √N, то необходимо указать величину ε, характеризующую требуемую точность вычислений. Определение корня состоит в многократном вычислении (пересчёте) по формуле, в правой части которой используется предыдущее значение переменной x. Вычисления следует прекратить при достижении требуемой точности, т.е. при выполнении условия |x_{i+1} − x_i| ≤ ε.

Пример 2. Сумма членов сходящегося ряда#

Составить алгоритм вычисления суммы членов сходящегося ряда:

S = 1 − x²/2! + x⁴/4! − x⁶/6! + ... + (−1)ⁿx^(2n)/(2n)! = Σ(n=0 до ∞) (−1)ⁿx^(2n)/(2n)!

с точностью ε.

Рекуррентная формула получения следующего члена ряда через предыдущий:

a_{n+1} = −a_n · x² / ((2n+1)(2n+2)), где n = 0, 1, 2, 3, ...

Значение a₀ = 1.

Эвристические алгоритмы#

Эвристика (от др. греч. εὑρίσκω (heuristiko), лат. Evrica — «отыскиваю», «открываю») — отрасль знания, изучающая творческое, неосознанное мышление человека.

Эвристика связана с психологией, физиологией высшей нервной деятельности, кибернетикой и другими науками, но сама как наука ещё полностью не сформировалась.

Эвристический алгоритм — алгоритм решения задачи, не имеющий строгого обоснования, но, тем не менее, дающий приемлемое решение задачи в большинстве практически значимых случаев.

Эвристический алгоритм — это алгоритм решения задачи, правильность которого для всех возможных случаев не доказана, но про который известно, что он даёт достаточно хорошее решение в большинстве случаев. В действительности может быть даже известно (то есть доказано) то, что эвристический алгоритм формально неверен. Его всё равно можно применять, если при этом он даёт неверный результат только в отдельных, достаточно редких и хорошо выделяемых случаях, или же даёт неточный, но всё же приемлемый результат.

Проще говоря, эвристика — это не полностью математически обоснованный (или даже «не совсем корректный»), но при этом практически полезный алгоритм.

Важно понимать, что эвристика, в отличие от корректного алгоритма решения задачи, обладает следующими особенностями:
- Она не гарантирует нахождение лучшего решения.
- Она не гарантирует нахождение решения, даже если оно заведомо существует (возможен «пропуск цели»).
- Она может дать неверное решение в некоторых случаях.

Применение эвристических алгоритмов#

Эвристические алгоритмы широко применяются:

  • В практике принятия управленческих решений.
  • Для решения задач высокой вычислительной сложности. В таких случаях, например, вместо алгоритма, в котором реализуется полный перебор вариантов, занимающий существенное время, а иногда технически невозможный, применяется значительно более быстрый, но недостаточно теоретически обоснованный алгоритм.
  • В областях искусственного интеллекта.
  • В задачах распознавания образов.
  • По причине отсутствия общего решения поставленной задачи.
  • Применяются в антивирусных программах, компьютерных играх и т.д.

По большому счёту многие из разрабатываемых программ носят эвристический характер — не во всех случаях программисты обеспокоены проблемами, связанными с доказательством правильности их программ.

Следует отметить, что при применении перечисленных методов важное место занимают задачи формализации данных и разработка информационных моделей.

Муравьиные алгоритмы#

Практическая необходимость решения ряда задач в оптимизационной постановке при проектировании и исследовании сложных систем привела разработчиков алгоритмического обеспечения к использованию биологических механизмов поиска наилучших решений.

Муравьи — социальные насекомые, которые живут в коллективе — колонии. Число муравьёв в одной колонии может достигать нескольких миллионов, а сама колония может быть распределена по территории в десятки и сотни километров. В то же время колония не имеет централизованного управления, а обмен информацией между особями осуществляется при помощи непрямого обмена, благодаря феромону, который муравьи оставляют в процессе своего передвижения в пространстве.

Феромоны (др.-греч. φέρω — несу + ὁρμάω — возбуждаю, побуждаю) — собирательное название веществ — продуктов внешней секреции, выделяемых некоторыми видами животных и обеспечивающих химическую коммуникацию между особями одного вида.

Муравьиный алгоритм (алгоритм оптимизации подражанием муравьиной колонии, ant colony optimization, ACO) — один из эффективных полиномиальных алгоритмов для нахождения приближённых решений задачи коммивояжёра, а также решения аналогичных задач поиска маршрутов на графах.

Коммивояжёр — это муравей, которому необходимо по кратчайшему маршруту посетить все пункты, ни разу не вернувшись в тот, где он уже был.

Принцип работы#

Оригинальная идея исходит от наблюдения за муравьями в процессе поиска кратчайшего пути от колонии до источника питания.

Среди экспериментов по выбору между двумя путями неравной длины, ведущих от колонии к источнику питания, биологи заметили, что, как правило, муравьи используют кратчайший маршрут. Модель такого поведения заключается в следующем:

  • Муравей проходит от колонии по пути, выбранному случайным образом.
  • Если он находит источник пищи, то возвращается в гнездо, оставляя за собой след из феромона.
  • Эти феромоны привлекают других муравьёв, находящихся вблизи, которые вероятнее всего пойдут по этому маршруту.
  • Вернувшись в гнездо, они «укрепят» феромонную тропу.
  • Если существует 2 маршрута, то по более короткому за то же время успеет пройти больше муравьёв, чем по длинному. Короткий маршрут станет более привлекательным.
  • Длинные пути, в конечном итоге, исчезнут из-за испарения феромонов.

Описанная система перемещения муравьёв базируется на положительной (другие муравьи укрепляют феромонную тропу) и отрицательной (испарение феромонной тропы) обратной связи.

Первая версия «муравьиного» алгоритма, предложенная доктором наук Марко Дориго в 1992 году, была направлена на поиск оптимального пути в графе.

Алгоритм Марко Дориго#

Алгоритм, предложенный Марко Дориго, основывается на обработке графа, в узлы которого помещаются источники питания для муравьёв (своего рода муравейники), а рёбра определяют маршруты, ведущие к источникам питания. Разработаны различные способы задания или описания графов, например, с помощью матрицы инцидентности.

Сам метод и реализация алгоритма используется автором для решения задачи коммивояжёра.

С учётом особенностей задачи коммивояжёра, мы можем описать локальные правила поведения муравьёв при выборе пути:

  1. Муравьи имеют собственную «память». Поскольку каждый город может быть посещён только один раз, то у каждого муравья есть список уже посещённых пунктов — список запретов. Обозначим через J_{i,k} — список пунктов, которые необходимо посетить муравью k, находящемуся в пункте i.

  2. Муравьи обладают «зрением». Видимость есть эвристическое желание посетить пункт j, если муравей находится в пункте i. Будем считать, что видимость обратно пропорциональна расстоянию между пунктами η_{i,j} = 1/D_{i,j}.

  3. Муравьи обладают «обонянием» — они могут улавливать след феромона, подтверждающий желание посетить пункт j из пункта i на основании опыта других муравьёв. Количество феромона на ребре (i,j) в момент времени t обозначим через τ_{i,j}(t).

  4. Пройдя ребро (i,j), муравей откладывает на нём некоторое количество феромона, которое должно быть связано с оптимальностью сделанного выбора.

Вычислительные формулы#

Вероятность перехода из i в j:

P_{i,j} = 100 · (η_{i,j}^β · τ_{i,j}^α) / (Σ_{k=0}^{N} η_{i,k}^β · τ_{i,k}^α)

где:
- N — число пунктов
- P_{i,j} — вероятность перехода из i в j
- η_{i,j} — величина, обратная длине перехода из i в j
- τ_{i,j} — количество феромона при переходе из i в j
- β — величина, определяющая «жадность» алгоритма
- α — величина, определяющая «стадность» алгоритма

При α = 0 алгоритм вырождается до жадного алгоритма (будет выбран ближайший город).

Правило обновления феромонов:

τ_{i,j}(t+1) = (1 − p) · τ_{i,j}(t) + Δτ_{i,j}(t)

Δτ_{i,j}(t) = Σ_{k=1}^{m} Δτ_{(i,j),k}(t)

где p ∈ [0,1] — коэффициент испарения, m — количество муравьёв в колонии.

Δτ_{(i,j),k} — откладываемое количество феромона k-ым муравьём на пути от i до j:

Δτ_{(i,j),k} = { Q / L_k(t),  (i,j) ∈ T_k(t)
               { 0,            (i,j) ∉ T_k(t)

T_k(t) — маршрут, пройденный муравьём k к моменту времени t; L_k(t) — длина этого маршрута; Q — параметр, имеющий значение порядка длины оптимального пути.

Муравьиный алгоритм для задачи коммивояжёра:#

  1. Ввод исходных данных и инициализация параметров настройки и переменных алгоритма. Исходными данными являются: количество муравейников-пунктов; количество муравьёв, приходящихся на один муравейник; матрица с расстояниями между пунктами.
  2. Определение вероятностей перехода из одного пункта в другой.
  3. Генерация случайного маршрута движения каждого муравья из последнего местонахождения с учётом рассчитанных вероятностей перехода; определение результирующей длины каждого маршрута (критерий оптимизации); сохранение в памяти вариантов маршрутов с наилучшим значением критерия.
  4. Расчёт изменения концентрации феромона между двумя муравейниками и нового значения концентрации феромона.
  5. Повторение процедуры с п. 2 до выполнения одного или нескольких выбранных условий окончания.
  6. Вывод вариантов маршрутов, обеспечивающих наилучшее значение критерия оптимальности.

Условием окончания может быть определённое время жизни колонии или отсутствие улучшения целевой функции на заданном промежутке времени.

Сложность данного алгоритма зависит от времени жизни колонии (t_max), количества городов (n) и количества муравьёв в колонии (m).

Недостатки муравьиного алгоритма#

  1. Теоретический анализ затруднён.
  2. Сходимость гарантируется, но время сходимости не определено.
  3. Сильно зависят от настроечных параметров, которые подбираются только исходя из экспериментов.

Генетические алгоритмы#

Генетические алгоритмы изначально разрабатывались для решения задач оптимизации. В основе генетического алгоритма лежит метод случайного поиска. Из теории оптимизации известно, что основным недостатком случайного поиска является то, что нам неизвестно, сколько времени понадобится для решения задачи.

Для того, чтобы избежать таких расходов времени при решении задачи, применяются методы, проявившиеся в биологии. Эти методы открыты при изучении эволюции и происхождения видов.

Исследователи обращаются к природным механизмам, которые миллионы лет обеспечивают адаптацию биоценозов к окружающей среде. В живой природе особи конкурируют друг с другом за различные ресурсы. Более приспособленные будут иметь больше шансов выжить. Одним из таких механизмов, имеющих фундаментальный характер, является механизм наследственности. Его использование для решения задач оптимизации привело к появлению генетических алгоритмов.

Впервые подобный алгоритм был предложен в 1975 году Джоном Холландом (John Holland) в Мичиганском университете. Он получил название "репродуктивный план Холланда" и лёг в основу практически всех вариантов генетических алгоритмов.

Генетический алгоритм можно описать следующими шагами:#

  1. Задать целевую функцию (приспособленности) для особей популяции.
  2. Создать начальную популяцию.
  3. Цикл по поколениям, пока не выполнено условие останова. В цикле:
  4. 1) Вычислить значение целевой функции для особей.
  5. 2) Оценить приспособленность каждой особи.
  6. 3) Выполнить селекцию по приспособленности (не все выживают).
  7. 4) Случайным образом разбить популяцию на две группы пар.
  8. 5) Выполнить скрещивание — кроссовер для пар популяции и заменить родителей на потомков.
  9. 6) Произвести вероятностную мутацию.
  10. 7) Объявить потомков новым поколением.
  11. Конец цикла по поколениям.

На этапе селекции нужно из всей популяции H выбрать определённую её долю, которая останется «в живых» на этом этапе эволюции. Есть разные способы проводить отбор. Вероятность выживания особи h должна зависеть от значения целевой функции. Сама доля выживших s обычно является параметром генетического алгоритма, и её просто задают заранее. По итогам отбора из N особей популяции H должны остаться sN особей, которые войдут в итоговую популяцию H'. Остальные особи погибают.

Для размножения обычно выбираются особи из всей популяции H (известны случаи, когда гениальные дети рождаются у «весьма неодарённых» родителей), а не из выживших на первом шаге элементов H₀ (хотя последний вариант тоже имеет право на существование). Дело в том, что главный бич многих генетических алгоритмов — недостаток разнообразия (diversity) в особях.

Другой важный момент — определение критериев останова. Обычно в качестве них применяются:
- Достижение определённого числа поколений.
- Истечение времени, отпущенного на эволюцию.
- Схождение популяции (путём сравнивания приспособленности популяции на нескольких поколениях и остановки при стабилизации этого параметра).

Под схождением понимается такое состояние популяции, когда ни операция кроссовера, ни операция мутации не вносят изменения в генетическое разнообразие популяции в течение нескольких поколений.

Генетический алгоритм не является волшебной палочкой при решении всех проблем минимизации/максимизации. Во множестве случаев другие алгоритмы гораздо быстрее и намного практичнее. Однако для задач с огромным количеством параметров и там где проблема сама по себе может быть легко определена, генетический алгоритм может быть подходящим выбором.