Skip to the content.

DSU

Расшифровывается как “disjoint set union”, на русском говорят “система непересекающихся множеств” или СНМ.

Идея такова: пусть есть n элементов, каким-то образом разбитых на непересекающиеся множества. DCU это структура данных, реализующая запросы двух видов: объединить два множества и сказать, в каком множестве лежит какой-то элемент. Для реализации структуры представим все множества в виде деревьев. Для каждого элемента будем хранить его предка, а для корня дерева - его самого. Тогда для того, чтобы найти множество элемента - пройдемся по предкам до его корня. Будем считать, что множество элемента задается корнем дерева, в котором он лежит.

Чтобы объединить два множества - найдем корни каждого из деревьев и подвесим одно из деревьев к другому.

Асимптотика такой реализации - $O(n)$ на запрос.

Наивная реализация

vector<int> p;

void init(n) {
    p.resize(n);
    iota(p.begin(), p.end(), 0);
}

int GetRoot(int i) {
    while (p[i] != i) {
        i = p[i];
    }
    return i;
}

void Merge(int i, int j) {
    int root_i = GetRoot(i);
    int root_j = GetRoot(j);
  
    if (root_i == root_j) {
        return;
    }
    p[root_i] = root_j;
}

Оптимизации

Ранговая эвристика

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

В таком случае асимптотика ответа на запрос резко уменьшится и станет равна $O(\log n)$. Действительно: пусть $size(v) = s$, тогда максимальная глубина вершины в дереве с корнем $v$ будет равна $log_2 s$, потому что каждый раз, когда глубина вершины увеличивается на 1 - размер ее дерева увеличивается хотя бы вдвое. А раз максимальная глубина - $\log_2 s$, то максимальная глубина вершины во всем дереве - $\log_2 n$, а значит, ответ на запрос будет делаться за $O(\log n)$.

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

Эвристика сжатия путей

Дополнительно к ранговой эвристике используем ещё одну: каждый раз, когда будем узнавать корень дерева вершины - будем подвешивать её непосредственно к корню. Это позволит дополнительно уменьшить расстояния от вершин до корня. Здесь не будет доказательства данного факта (поскольку оно слишком сложное), но оказывается, что тогда асимптотика ответа на запрос равна $O(\alpha(n))$. Здесь $\alpha(n)$ - обратная функция Аккермана, которая растёт настолько медленно, что для любых разумных $n: \alpha(n) \le 5$, то есть в среднем ответ на запрос будет получен за 5 операций.

Итоговый алгоритм (с эвристиками)

vector<int> p;
vector<int> size;

void init(n) {
  p.resize(n);
  p.iota(p.begin(), p.end(), 0);
  size.resize(n, 1);
}

int GetRoot(int i) {
  if (p[i] == i) {
    return i;
  }
  return p[i] = GetRoot(p[i]);
}


void Merge(int i, int j) {
  int root_i = GetRoot(i);
  int root_j = GetRoot(j);

  if (root_i == root_j) {
    return;  
  }

  if (size[root_i] < size[root_j]) {
    p[root_i] = root_j;
    size[root_j] += size[root_i];
  } else {
    p[root_j] = root_i;
    size[root_i] += size[root_j];
  }
}

Алгоритм Крускала

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

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

Доказательство корректности

Допустим, наш алгоритм некорректен и в итоге мы получим остов, не являющийся минимальным. Рассмотрим какой-нибудь миностов и сравним с нашим.

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

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

Реализация

Используется реализация СНМ, приведённая выше

struct Edge {
    int from, to, weight;
};

vector<Edge> edges;
vector<Edge> ans;

sort(edges.begin(), edges.end(), [](Edge a, Edge b) {
    return a.weight < b.weight;
});

for (auto e : edges) {
    if (GetRoot(e.from) != GetRoot(e.to)) {
        ans.push_back(e);
        Merge(e.from, e.to);
    }
}

Асимптотика алгоритма - $O(ElogE)$