Производительность коллекции обратного отображения - PullRequest
1 голос
/ 26 мая 2020

У меня есть набор объектов, где каждый объект имеет уникальный строковый идентификатор, а любой другой объект может содержать совершенно произвольные (многие к одному) «ссылки» на другой объект. Я также хочу иметь возможность сгенерировать «карту использования», которая является обратным индексом - для любого объекта, какие другие объекты связаны либо непосредственно с ним, либо с его дочерним элементом? (Здесь «дочерний» определяется как любой объект с совпадающим идентификатором префикса, поскольку идентификатор представляет собой обозначение пути с точками.)

Итак, Baz.Boz может быть одним объектом, который ссылается на Foo.Bar - тогда карта использования должна отражать, что Foo и Foo.Bar (но не Foo.Bob) используются Baz.Boz.

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

// builds Id => links that link to Id or one of Id's children (by prefix)
public IDictionary<string, IList<Link>> CalculateUsageMap()
{
    var all = All();
    var links = all.Values
        .SelectMany(o => o.Links ?? Enumerable.Empty<Link>())
        .ToList();
    return all.Keys.ToDictionary(i => i, i => links.Where(k => IsLinkedTo(k, i)).ToList());
    // this last line is very slow
}

private static bool IsLinkedTo(Link link, string candidateId)
{
    return !string.IsNullOrEmpty(link.TargetId)
        && !string.IsNullOrEmpty(candidateId)
        && link.TargetId.StartsWith(candidateId, StringComparison.Ordinal);
}

Это поддерживающая структура, стоящая за ним:

public interface ILinkable
{
    string Id { get; }
    IEnumerable<ILinkable> Children { get; }
    IEnumerable<Link> Links { get; }
}

public class Link
{
    public string Name { get; }
    public ILinkable Source { get; } // our immediate owner
    public string TargetId { get; }
    // plus constructors etc that's irrelevant at present
}

public ILinkable Root { get; }

public IDictionary<string, ILinkable> All()
{
    var tree = new Dictionary<string, ILinkable>();
    AddWithDescendants(tree, Root);
    return tree;
}

private static void AddWithDescendants(IDictionary<string, ILinkable> tree, ILinkable obj)
{
    tree.Add(obj.Id, obj);

    foreach (var child in obj.Children ?? Enumerable.Empty<ILinkable>())
    {
        AddWithDescendants(tree, child);
    }
}

Это работает, но в дереве с ~ 14k объектами и ~ 3k ссылками (производя ~ 20k использования) это занимает ~ 5 секунд для генерации, что длиннее, чем хотелось бы. (Я проверил и All(), и вычисление links практически не занимает времени; все это тратится внутри ToDictionary.)

Есть ли способ улучшить производительность этой строки? Моя первая мысль была использовать что-то вроде GroupJoin, но поскольку мы «присоединяемся» к равенству префиксов, а не к фактическому равенству, это не работает. Я бы предпочел сохранить это в чистом коде, не связанном с базой данных.

(Я попытался написать собственный компаратор равенства для GroupJoin, но это оказалось медленнее и дало неправильные результаты, только ~ 7k выходных данных использования. И это в любом случае сомнительно, поскольку это асимметричное c совпадение, в то время как компараторы равенства предполагают симметрию.)

Ответы [ 2 ]

2 голосов
/ 29 мая 2020

Временная сложность этого кода

return all.Keys.ToDictionary(i => i, i => links.Where(k => IsLinkedTo(k, i)).ToList());

- это квадратичные c O(N*M), где N - all.Keys.Count, а M - links.Count. Поэтому неудивительно, что это медленный. . Такая структура данных существует и называется Префиксное дерево . Ниже приведена быстрая реализация для вашего случая:

class ItemMap : IReadOnlyCollection<KeyValuePair<string, IReadOnlyList<Link>>>
{
    class Node
    {
        public Node(char key) => Key = key;
        public char Key { get; }
        public NodeMap Children;
        public ILinkable Item;
        public List<Link> Links;
        public IReadOnlyList<Link> ItemLinks => Links ?? (Item != null ? NoLinks : null);
        public static IReadOnlyList<Link> NoLinks => Array.Empty<Link>(); 
    }

    struct NodeMap
    {
        Dictionary<char, Node> items;
        public IEnumerable<Node> Items => items?.Values;
        public bool TryGetItem(char key, out Node item, bool create = false)
        {
            item = null;
            if ((items == null || !items.TryGetValue(key, out item)) && create)
                (items ?? (items = new Dictionary<char, Node>())).Add(key, item = new Node(key));
            return item != null;
        }
    }

    NodeMap RootNodes;

    IEnumerable<Node> Nodes
        => RootNodes.Items?.Expand(e => e.Children.Items) ?? Enumerable.Empty<Node>();

    IEnumerable<Node> ItemNodes
        => Nodes.Where(n => n.Item != null);

    IEnumerable<KeyValuePair<string, IReadOnlyList<Link>>> Items
        => ItemNodes.Select(n => new KeyValuePair<string, IReadOnlyList<Link>>(n.Item.Id, n.ItemLinks));

    public ItemMap(ILinkable tree)
    {
        if (tree == null) return;
        var items = new[] { tree }.Expand(e => e.Children);
        foreach (var item in items)
            AddItem(item);
        var links = Nodes.Where(n => n.Item?.Links != null).SelectMany(n => n.Item.Links);
        foreach (var link in links)
            AddLink(link);
    }

    void AddItem(ILinkable item)
    {
        var node = GetNode(item.Id, create: true);
        if (node == null) return;
        if (node.Item != null) throw new Exception($"Duplicate key: {item.Id}");
        node.Item = item;
        Count++;
    }

    void AddLink(Link link)
    {
        var key = link.TargetId;
        if (string.IsNullOrEmpty(key)) return;
        ref var nodes = ref RootNodes;
        for (int i = 0; i < key.Length; i++)
        {
            if (!nodes.TryGetItem(key[i], out var node)) break;
            // Add to each item in the prefix path
            if (node.Item != null && node.Item != link.Source)
                (node.Links ?? (node.Links = new List<Link>())).Add(link);
            nodes = ref node.Children;
        }
    }

    Node GetNode(string key, bool create = false)
    {
        if (string.IsNullOrEmpty(key)) return null;
        Node node = null;
        ref var nodes = ref RootNodes;
        for (int i = 0; i < key.Length; i++)
        {
            if (!nodes.TryGetItem(key[i], out node, create)) break;
            nodes = ref node.Children;
        }
        return node;
    }

    public int Count { get; private set; }

    public IEnumerator<KeyValuePair<string, IReadOnlyList<Link>>> GetEnumerator() => Items.GetEnumerator();

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

(Примечание: вместо рекурсивного обхода дерева во многих местах я использую метод generi c DRY Expand из моего ответа на Как сгладить дерево через LINQ? ))

Вся тяжелая работа выполняется в конструкторе класса. Первый проход добавляет все элементы в префиксное дерево. Затем второй проход связывает ссылки с каждым элементом. Первая операция - O(N), вторая - O(M), так что в целом получается O(M+N), т.е. линейно и намного быстрее, чем оригинал.

Теперь вы можете легко создать нужный словарь (я изменил интерфейсы к их ReadOnly вариантам):

public IReadOnlyDictionary<string, IReadOnlyList<Link>> CalculateUsageMap()
    => new ItemMap(Root).ToDictionary(e => e.Key, e => e.Value);

Но учтите, что это даже не нужно, потому что дерево префиксов может эффективно использоваться как словарь. Реализовать интерфейс словаря только для чтения довольно просто - измените объявление класса на

class ItemMap : IReadOnlyDictionary<string, IReadOnlyList<Link>> 

и добавьте следующие несколько строк внутри

public IEnumerable<string> Keys => Items.Select(e => e.Key);

public IEnumerable<IReadOnlyList<Link>> Values => Items.Select(e => e.Value);

public IReadOnlyList<Link> this[string key]
    => TryGetValue(key, out var value) ? value : throw new KeyNotFoundException();

public bool ContainsKey(string key) => TryGetValue(key, out _);

public bool TryGetValue(string key, out IReadOnlyList<Link> value)
    => (value = GetNode(key)?.ItemLinks) != null;

Теперь вы можете удалить вызов ToDictionary:

public IReadOnlyDictionary<string, IReadOnlyList<Link>> CalculateUsageMap()
    => new ItemMap(Root);

Приведенная выше реализация префиксного дерева использует Dictionary<char, Node> для хранения / обхода списка дочерних узлов. Это может занять гораздо больше памяти. Поскольку вся реализация инкапсулирована в NodeMap, вы можете экспериментировать с различными структурами данных и измерять производительность и использование памяти.

Например, следующая реализация, которая использует null, одиночный Node или отсортированный List<Node> в качестве хранилища и двоичный поиск для поиска Node по ключу в отсортированном списке:

struct NodeMap
{
    object items; // null, Node or sorted List<Node>
    public IEnumerable<Node> Items => items is Node node ? new[] { node } : items as IEnumerable<Node>;
    public bool TryGetItem(char key, out Node item, bool create = false)
    {
        item = null;
        if (items == null)
        {
            if (create) items = item = new Node(key);
        }
        else if (items is Node node)
        {
            if (node.Key == key) item = node;
            else if (create) items = node.Key < key ? new List<Node>(2) { node, (item = new Node(key)) } : new List<Node>(2) { (item = new Node(key)), node };
        }
        else
        {
            var nodeList = (List<Node>)items;
            int lo = 0, hi = nodeList.Count - 1;
            while (lo <= hi)
            {
                int mid = lo + ((hi - lo) >> 1);
                node = nodeList[mid];
                if (node.Key == key) { item = node; break; }
                if (node.Key < key) lo = mid + 1; else hi = mid - 1;
            }
            if (item == null && create) nodeList.Insert(lo, item = new Node(key));
        }
        return item != null;
    }
}
1 голос
/ 05 июня 2020

Для справки, вот версия NodeMap из ответа Ивана , в котором вместо этого используется встроенный List<T>.BinarySearch:

struct NodeMap
{
    object items; // null, Node or sorted List<Node>
    private static readonly IComparer<Node> NodeComparer
        = Comparer<char>.Default.SelectComparer((Node n) => n.Key);
    public IEnumerable<Node> Items => items is Node node
        ? new[] { node } : items as IEnumerable<Node>;
    public bool TryGetItem(char key, out Node item, bool create = false)
    {
        item = null;
        if (items == null)
        {
            if (create) items = item = new Node(key);
        }
        else if (items is Node node)
        {
            if (node.Key == key) item = node;
            else if (create) items = node.Key < key
               ? new List<Node>(2) { node, (item = new Node(key)) }
               : new List<Node>(2) { (item = new Node(key)), node };
        }
        else
        {
            var nodeList = (List<Node>)items;
            var newNode = new Node(key);
            var index = nodeList.BinarySearch(newNode, NodeComparer);
            if (index >= 0) item = nodeList[index];
            else if (create) nodeList.Insert(~index, (item = newNode));
        }
        return item != null;
    }
}

Обратите внимание, что здесь я использование вспомогательного класса для генерации IComparer<Node> из лямбды; эквивалентно, вы можете сделать Node реализовать IComparable<T> (хотя это может быть немного медленнее, по-видимому?).

Также интересно то, что я также пробовал альтернативную версию, которая всегда создавала List<Node> вместо поддержки листовые узлы (исключая среднюю ветвь), но это удвоило время с ~ 0,10 с до ~ 0,20 с. Этого все равно было бы достаточно, но с таким же успехом можно было бы go с самым быстрым вариантом.


Еще один вариант: поскольку мои идентификаторы представляют собой сегменты с точечными путями, которые являются atomi c (ie Меня интересуют только префиксы сегментов пути, а не префиксы подстроки пути), вместо этого я попытался использовать строковые ключи, разделенные на уровне сегмента; заменив NodeMap вот так:

struct NodeMap
{
    object items; // null, Node or sorted List<Node>
    private static readonly IComparer<Node> NodeComparer
        = StringComparer.Ordinal.SelectComparer((Node n) => n.Key);
    public IEnumerable<Node> Items => items is Node node
        ? new[] { node } : items as IEnumerable<Node>;
    public bool TryGetItem(string key, out Node item, bool create = false)
    {
        item = null;
        if (items == null)
        {
            if (create) items = item = new Node(key);
        }
        else if (items is Node node)
        {
            if (node.Key == key) item = node;
            else if (create) items = StringComparer.Ordinal.Compare(node.Key, key) < 0
                ? new List<Node>(2) { node, (item = new Node(key)) }
                : new List<Node>(2) { (item = new Node(key)), node };
        }
        else
        {
            var nodeList = (List<Node>)items;
            var newNode = new Node(key);
            var index = nodeList.BinarySearch(newNode, NodeComparer);
            if (index >= 0) item = nodeList[index];
            else if (create) nodeList.Insert(~index, (item = newNode));
        }
        return item != null;
    }
}

А затем в AddLink / GetNode изменил ключ l oop на:

foreach (var segment in key.Split('.'))

Теперь производительность снова улучшилась (назад к ~ 0,05 с, аналогично исходной словарной версии Ивана). И хотя я не проверил все записи, он дает то же количество результатов, что и «заведомо хорошая» версия, поэтому он все равно должен быть правильным.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...