Привет, Хабр!

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

Формулы, используемые в теории сложности, часто связаны с вычислительной сложностью задач. Например, NP-полные задачи, которые являются одними из самых сложных для вычисления, описываются с помощью полиномиальных уравнений. Сложность задачи может быть выражена как O(n^k), где n — размер входных данных, а k — степень, определяющая сложность алгоритма.

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

Базовые принципы сложных систем

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

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

Самоорганизация — это способность системы к спонтанной организации и адаптации.

К примеру концепция самоорганизации может выглядеть так:

import random

def agent_action():
    return random.choice(['move', 'stay', 'interact'])

def simulate_agents(num_agents, steps):
    for _ in range(steps):
        actions = [agent_action() for _ in range(num_agents)]
        print(f"Actions at step: {actions}")

simulate_agents(5, 10)

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

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

Допустим, у нас есть СУБД. Сложность этой системы можно оценить, исследуя количество операций, которые она может выполнять (например, чтение, запись, обновление, удаление), а также разнообразие запросов и взаимодействий между различными частями системы:

Адаптивность против эффективности

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

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

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

Сложность в средах систем

Закон необходимого разнообразия

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

Несоответствия сложности в подразделенных системах

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

Иерархические структуры в сложных системах

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

Влияние NP-полноты на системную аналитику

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

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

Задача о рюкзаке (Knapsack Problem)

def knapsack(W, wt, val, n):
    if n == 0 or W == 0:
        return 0
    if wt[n-1] > W:
        return knapsack(W, wt, val, n-1)
    else:
        return max(val[n-1] + knapsack(W-wt[n-1], wt, val, n-1),
                   knapsack(W, wt, val, n-1))

# Тестовые данные
val = [60, 100, 120]
wt = [10, 20, 30]
W = 50
n = len(val)

print(knapsack(W, wt, val, n))

Задача коммивояжера (Travelling Salesman Problem, TSP)

from sys import maxsize

def travellingSalesmanProblem(graph, s):
    vertex = []
    for i in range(len(graph)):
        if i != s:
            vertex.append(i)

    min_path = maxsize
    while True:
        current_pathweight = 0

        k = s
        for i in range(len(vertex)):
            current_pathweight += graph[k][vertex[i]]
            k = vertex[i]
        current_pathweight += graph[k][s]

        min_path = min(min_path, current_pathweight)

        if not next_permutation(vertex):
            break

    return min_path

def next_permutation(L):
    n = len(L)
    i = n - 2
    while i >= 0 and L[i] >= L[i + 1]:
        i -= 1

    if i == -1:
        return False

    j = i + 1
    while j < n and L[j] > L[i]:
        j += 1
    j -= 1

    L[i], L[j] = L[j], L[i]

    left = i + 1
    right = n - 1

    while left < right:
        L[left], L[right] = L[right], L[left]
        left += 1
        right -= 1

    return True

# Тестовый граф
graph = [[0, 10, 15, 20],
         [10, 0, 35, 25],
         [15, 35, 0, 30],
         [20, 25, 30, 0]]
s = 0
print(travellingSalesmanProblem(graph, s))

Это классические NP-полные задачи.

Проблема P против NP

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

Для иллюстрации разницы между P и NP-полными задачами, рассмотрим два алгоритма: один для задачи с полиномиальным временем выполнения (P) и другой для NP-полной задачи.

Пример Алгоритма P (Полиномиальное время)

# Простой алгоритм поиска в глубину для графа

def depth_first_search(graph, start):
    visited, stack = set(), [start]
    while stack:
        vertex = stack.pop()
        if vertex not in visited:
            visited.add(vertex)
            stack.extend(graph[vertex] - visited)
    return visited

graph = {0: set([1, 2]), 1: set([0, 2]), 2: set([0, 1, 3]), 3: set([2])}
print(depth_first_search(graph, 0))

Пример NP-полной задачи (Задача коммивояжера)

import itertools

def travelling_salesman_problem(graph, s):
    vertex = [i for i in range(len(graph)) if i != s]
    min_path = float('inf')
    for perm in itertools.permutations(vertex):
        current_pathweight = 0
        k = s
        for i in perm:
            current_pathweight += graph[k][i]
            k = i
        current_pathweight += graph[k][s]
        min_path = min(min_path, current_pathweight)
    return min_path

graph = [[0, 10, 15, 20], [10, 0, 35, 25], [15, 35, 0, 30], [20, 25, 30, 0]]
s = 0
print(travelling_salesman_problem(graph, s))

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

Подходы к решению NP-полных задач

1. Аппроксимационные методы

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

Аппроксимация задачи о рюкзаке

def knapsack_approx(values, weights, W):
    ratio = sorted([(v / w, w) for v, w in zip(values, weights)], reverse=True)
    total_value = 0
    for r, w in ratio:
        if W >= w:
            W -= w
            total_value += r * w
    return total_value

values = [60, 100, 120]
weights = [10, 20, 30]
W = 50
print(knapsack_approx(values, weights, W))

2. Рандомизированные алгоритмы

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

Рандомизированный поиск

import random

def randomized_search(max_iter, domain):
    best_solution = None
    best_cost = float('inf')
    for _ in range(max_iter):
        solution = [random.randint(domain[i][0], domain[i][1]) for i in range(len(domain))]
        cost = objective_function(solution)
        if cost < best_cost:
            best_cost = cost
            best_solution = solution
    return best_solution

domain = [(0, 10)] * 3 # Примерный домен
max_iter = 1000
print(randomized_search(max_iter, domain))

3. Ограничение и параметризация

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

4. Эвристические методы

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

Жадный алгоритм:

def greedy_algorithm(items, limit):
    items.sort(key=lambda x: x.value / x.weight, reverse=True)
    total_value = 0
    for item in items:
        if limit >= item.weight:
            limit -= item.weight
            total_value += item.value
    return total_value

# Предполагаем, что items - список объектов с атрибутами value и weight
print(greedy_algorithm(items, 50))

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

Заключение

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

Напоследок хочу порекомендовать вам бесплатный урок по теме "Декомпозиция задач и аналитик".

Комментарии (2)


  1. accurate_random
    21.11.2023 03:37

    А что с случаем, когда входные данные только ограничивают обьем выходных данных? Что происходит тогда в ракурсе теории?


  1. SwetlanaF
    21.11.2023 03:37

    Добрый день! Огромное спасибо за статью (хотела лайкнуть, но, увы, не получилось). Начала изучать питон, и как раз попалась ваша статья. Посмотрю как все эти алгоритмы на питоне пишутся, возьму за образец))

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

    1. Можно было в начале статьи упомянуть о разумных схемах кодирования - чтобы искусственное раздувание входа не превратило в NP-hard в P.

    2. Можно было упомянуть отдельный подкласс NP-полных задач, которые решаются за псевдополиномиальное время динамическим программированием. Классический пример (собственно, задача тысячелетия) - набор заданной суммы B с помощью n заданных целых слагаемых. Сложность Ο(nB) - не экспоненциальная. Тут вот и разумные схемы кодирования пригодились бы.

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

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