Алгоритм Генеалогического Древа
Я работаю над созданием набора задач для курса CS на уровне ввода и придумал вопрос, который на первый взгляд кажется очень простым:
вам дается список людей с именами их родителей, даты их рождения и даты их смерти. Вы заинтересованы в том, чтобы выяснить, кто в какой-то момент своей жизни был родителем, дедушкой, прадедушкой и т. д. Разработайте алгоритм, чтобы пометить каждого человека этой информацией как целое число (0 означает, что у человека никогда не было ребенка, 1 означает, что человек был родителем, 2 означает, что человек был бабушкой и дедушкой и т. д.)
для простоты можно предположить, что семейный граф является DAG, неориентированная версия которого является деревом.
здесь интересная задача заключается в том, что вы не можете просто посмотреть на форму дерева, чтобы определить эту информацию. Например, у меня есть 8 прапрадедушек и прабабушек, но так как никто из них не был жив, когда я родился, в их жизни никто из них не был прапрадедушкой и прабабушкой.
лучший алгоритм, который я могу придумать для этой проблемы, работает во времени O (n2), где n-количество людей. Идея проста-начать DFS от каждого человека, найти самого дальнего потомка в генеалогическом древе, который родился до даты смерти этого человека. Однако, я уверен, что это не оптимальное решение проблемы. Например, если на графике всего два родителя и их n детей, то задача может быть решена тривиально в O (n). Я надеюсь на какой-то алгоритм, который либо бьет O (n2) или чья среда выполнения параметризована по форме графика, что делает ее быстрой для широких графов с изящным ухудшением до O (n2) в худшем случае.
9 ответов
Я думал об этом сегодня утром, а затем обнаружил, что у @Alexey Kukanov были похожие мысли. Но мой более плотный и имеет некоторую оптимизацию, поэтому я все равно опубликую его.
этот алгоритм O(n * (1 + generations))
и будет работать для любого набора данных. Для реалистичных данных это O(n)
.
- выполнить все записи и генерировать объекты, представляющие людей, которые включают дату рождения, ссылки на родителей и ссылки на детей, и еще несколько неинициализированных поля. (Время последней смерти между собой и предками и массив дат, которые у них были 0, 1, 2,... выжившие поколения.)
- пройдите через всех людей и рекурсивно найдите и сохраните время последней смерти. Если вы позвоните человеку еще раз, верните записанную запись. Для каждого человека вы можете столкнуться с человеком (нужно вычислить его) и может генерировать еще 2 вызова каждому родителю при первом вычислении. Это дает в общей сложности
O(n)
работа для инициализации этого данные. - пройдите через всех людей и рекурсивно создайте запись о том, когда они впервые добавили поколение. Эти записи должны идти только до максимума, когда человек или их последний предок умер. Это
O(1)
для расчета, когда у вас было 0 поколений. Тогда для каждого рекурсивного вызова ребенку нужно сделатьO(generations)
работа, чтобы объединить данные этого ребенка в ваш. Каждый человек вызывается, когда вы сталкиваетесь с ними в структуре данных, и может быть вызван один раз от каждого родителя дляO(n)
звонки и общий расходO(n * (generations + 1))
. - пройдите через всех людей и выяснить, сколько поколений были живы в момент их смерти. Это опять
O(n * (generations + 1))
если реализовано с линейным сканированием.
общая сумма всех этих операций составляет O(n * (generations + 1))
.
для реальных наборов данных, это будет O(n)
С достаточно малой константой.
обновление: это не лучшее решение, которое я придумал, но я оставил его, потому что есть так много комментариев, связанных с ним.
у вас есть набор событий (рождение / смерть), родительское состояние (нет потомков, родителя, дедушки и т. д.) и состояние жизни (живое, мертвое).
Я бы хранил свои данные в структурах со следующими полями:
mother
father
generations
is_alive
may_have_living_ancestor
Сортировать события по дате, а затем для каждого события принять один из следующих двух курсов логика:
Birth:
Create new person with a mother, father, 0 generations, who is alive and may
have a living ancestor.
For each parent:
If generations increased, then recursively increase generations for
all living ancestors whose generations increased. While doing that,
set the may_have_living_ancestor flag to false for anyone for whom it is
discovered that they have no living ancestors. (You only iterate into
a person's ancestors if you increased their generations, and if they
still could have living ancestors.)
Death:
Emit the person's name and generations.
Set their is_alive flag to false.
в худшем случае O(n*n)
Если у каждого есть много живых предков. Однако в целом у вас есть шаг предварительной обработки сортировки, который O(n log(n))
и тогда ты O(n * avg no of living ancestors)
что означает, что общее время стремится быть O(n log(n))
в большинстве популяций. (Я не посчитал шаг сортировки должным образом, благодаря @Alexey Kukanov за исправление.)
мое предложение:
- в дополнение к значениям, описанным в постановке задачи, каждая личная запись будет иметь два поля: дочерний счетчик и динамически растущий вектор (в смысле C++/STL), который сохранит самый ранний день рождения в каждом поколении потомков человека.
- используйте хэш-таблицу для хранения данных, при этом имя человека является ключом. Время его построения линейно (при условии хорошей хэш-функции карта имеет амортизированную константу время вставок и находок).
- для каждого человека, обнаружить и сохранить количество детей. Это также делается в линейном времени: для каждой личной записи найдите запись для своих родителей и увеличьте их счетчики. Этот шаг можно объединить с предыдущим: если запись для родителя не найдена, она создается и добавляется, а детали (даты и т. д.) будут добавлены при обнаружении во входных данных.
- пройти карту, и поставить ссылки на все личные записи без детей в очередь. Еще
O(N)
. - для каждого элемента, взятого из очереди:
- добавить рождения этого человека в
descendant_birthday[0]
для обоих родителей (при необходимости увеличьте этот вектор). Если это поле уже задано, измените его, только если новая дата более ранняя. - для всех
descendant_birthday[i]
даты, доступные в векторе текущей записи, следуйте тому же правилу, что и выше, чтобы обновитьdescendant_birthday[i+1]
в родительской записи. - decrement родителей ребенка счетчики; если это достигает 0, добавьте запись соответствующего родителя в очередь.
- стоимость этого шага
O(C*N)
, причем C является самым большим значением "глубины семейства" для данного ввода (т. е. размера самого длинногоdescendant_birthday
вектор). Для реалистичных данных он может быть ограничен некоторой разумной константой без потери корректности (как уже указывали другие), и поэтому не зависит от N.
- добавить рождения этого человека в
- пройдите карту еще раз, и "обозначьте каждого человека" с самым большим
i
для чегоdescendant_birthday[i]
еще раньше, чем дата смерти; такжеO(C*N)
.
таким образом, для реалистичных данных решение задачи можно найти в линейном времени. Хотя для надуманных данных, как предложено в комментарии @btilly, C может быть большим и даже Порядка N в вырожденных случаях. Он может быть разрешен либо путем установки крышки на размер вектора, либо путем расширения алгоритма с шагом 2 решения @btilly.
хэш-таблица является ключевой частью решение в случае, если отношения родитель-потомок во входных данных предоставляются через имена (как написано в постановке задачи). Без хэшей это потребовало бы O(N log N)
для построения графика отношений. Большинство других предлагаемых решений, по-видимому, предполагают, что граф отношений уже существует.
создать список людей, отсортированных по birth_date
. Создайте еще один список людей, отсортированных по death_date
. Вы можете путешествовать логически во времени, вытаскивая людей из этих списков, чтобы получить список событий, как они произошли.
для каждого человека, определить
ниже приведен алгоритм O(N log n), который работает для графиков, в которых каждый ребенок имеет не более одного родителя (EDIT: этот алгоритм не распространяется на случай с двумя родителями с производительностью O(n log n)). Стоит отметить, что я считаю, что производительность может быть улучшена до O(N log(Max level label)) с дополнительной работой.
один родитель делу:
для каждого узла x в обратном топологическом порядке создайте двоичное дерево поиска T_x, которое строго увеличивает оба по дате рождения и по числу поколений, удаленных от X. (T_x содержит первенца c1 в подграфе графа предков, коренящегося в x, вместе со следующим самым ранним ребенком c2 в этом подграфе, таким образом, что "уровень прадеда C2" строго больше, чем у c1, вместе со следующим самым ранним ребенком c3 в этом подграфе, так что уровень c3 строго больше, чем у c2, и т. д.) Чтобы создать T_x, мы объединяем ранее построенные деревья T_w, где w является дочерним x (они ранее построены, потому что мы итерируем в обратном топологическом порядке).
Если мы будем осторожны с тем, как мы выполняем слияния, мы можем показать, что общая стоимость таких слияний составляет O(N log n) для всего графа предков. Ключевая идея состоит в том, чтобы отметить, что после каждого слияния в объединенном дереве сохраняется не более одного узла каждого уровня. Мы связываем с каждым деревом t_w потенциал H (w) log n, где h( w) равен длине самого длинного пути от w до a лист.
когда мы объединяем дочерние деревья T_w для создания T_x, мы "уничтожаем" все деревья T_w, освобождая весь потенциал, который они хранят для использования при построении дерева T_x; и мы создаем новое дерево T_x с (log n)(H(x)) потенциалом. Таким образом, наша цель - потратить не более O((log n)(sum_w(h(w)) - h(x) + constant)) времени на создание T_x из деревьев T_w так, чтобы амортизированная стоимость слияния была только O(log n). Этого можно достичь, выбрав дерево T_w таким, чтобы h (w) было максимальным в качестве отправной точки для T_x, а затем изменение T_w для создания T_x. После того, как такой выбор сделан для T_x, мы объединяем каждое из других деревьев, одно за другим, в T_x с алгоритмом, который аналогичен стандартному алгоритму объединения двух бинарных деревьев поиска.
по существу, слияние выполняется путем итерации по каждому узлу y в T_w, поиска предшественника y по дате рождения, а затем вставки y в T_x, если из x удалено больше уровней, чем z; затем, если z был вставлен в T_x мы ищем узел в T_x самого низкого уровня, который строго больше уровня z, и сращиваем промежуточные узлы, чтобы поддерживать инвариант, что T_x строго упорядочен как по дате рождения, так и по уровню. Это стоит O(log n) для каждого узла в T_w, и в T_w есть не более O(h (w)) узлов, поэтому общая стоимость слияния всех деревьев составляет O ((log n) (sum_w(h(w))), суммируя все дочерние W, за исключением дочернего w' такого, что h (w') максимален.
мы храним уровень связан с каждым элементом T_x во вспомогательном поле каждого узла дерева. Нам нужно это значение, чтобы мы могли вычислить фактический уровень x, как только мы построим T_x. (В качестве технической детали мы фактически храним разницу уровня каждого узла с уровнем его родителя в T_x, чтобы мы могли быстро увеличить значения для всех узлов в дереве. Это стандартный трюк BST.)
У меня есть предчувствие, что получение для каждого человека отображения (поколение -> дата рождения первого потомка в этом поколении) поможет.
поскольку даты должны строго увеличиваться, мы могли бы использовать двоичный поиск (или аккуратную структуру данных), чтобы найти самый отдаленный живой потомок в O(log n) времени.
проблема в том, что объединение этих списков (по крайней мере, наивно) - это O (количество поколений), поэтому это может быть O (n^2) в худшем случае (рассмотрим и Б родителей C и D, которые являются родителями E и F...).
Мне все еще нужно выяснить, как работает лучший случай, и попытаться лучше идентифицировать худшие случаи (и посмотреть, есть ли обходной путь для них)
недавно мы реализовали модуль отношений в одном из наших проектов, в котором у нас было все в базе данных, и да, я думаю, что алгоритм был лучшим 2nO(m) (m-максимальный коэффициент ветви). Я умножил операции дважды на N, потому что в первом раунде мы создаем график отношений, а во втором раунде посещаем каждого человека. Мы сохранили двунаправленные отношения между каждыми двумя узлами. Во время навигации мы используем только одно направление. Но у нас есть два набора операций, один пересекает только детей, другой траверс только родительский.
Person{
String Name;
// all relations where
// this is FromPerson
Relation[] FromRelations;
// all relations where
// this is ToPerson
Relation[] ToRelations;
DateTime birthDate;
DateTime? deathDate;
}
Relation
{
Person FromPerson;
Person ToPerson;
RelationType Type;
}
enum RelationType
{
Father,
Son,
Daughter,
Mother
}
этот вид выглядит как двунаправленный график. Но в этом случае сначала вы создаете список всех людей, а затем вы можете построить отношения списка и настроить FromRelations и ToRelations между каждым узлом. Тогда все, что вам нужно сделать, это для каждого человека вам нужно только перемещаться по типам (сын,дочь). И так как у вас есть дата, вы можете рассчитать все.
у меня нет времени проверять правильность кода, но это дадут вам представление о том, как это сделать.
void LabelPerson(Person p){
int n = GetLevelOfChildren(p, p.birthDate, p.deathDate);
// label based on n...
}
int GetLevelOfChildren(Person p, DateTime bd, DateTime? ed){
List<int> depths = new List<int>();
foreach(Relation r in p.ToRelations.Where(
x=>x.Type == Son || x.Type == Daughter))
{
Person child = r.ToPerson;
if(ed!=null && child.birthDate <= ed.Value){
depths.Add( 1 + GetLevelOfChildren( child, bd, ed));
}else
{
depths.Add( 1 + GetLevelOfChildren( child, bd, ed));
}
}
if(depths.Count==0)
return 0;
return depths.Max();
}
вот мой стаб:
class Person
{
Person [] Parents;
string Name;
DateTime DOB;
DateTime DOD;
int Generations = 0;
void Increase(Datetime dob, int generations)
{
// current person is alive when caller was born
if (dob < DOD)
Generations = Math.Max(Generations, generations)
foreach (Person p in Parents)
p.Increase(dob, generations + 1);
}
void Calculate()
{
foreach (Person p in Parents)
p.Increase(DOB, 1);
}
}
// run for everyone
Person [] people = InitializeList(); // create objects from information
foreach (Person p in people)
p.Calculate();
существует относительно простой алгоритм O(N log n), который подметает события хронологически с помощью подходящего топ-дерево.
вы действительно не должны назначать домашнее задание, которое вы не можете решить сами.