Как повысить производительность этого алгоритма?
У меня есть текстовый файл со 100 000 пар: слово и частота.
test.in файл со словами:
- 1 строка - общее количество всех пар слов-слов
- 2 строки до ~ 100 001 - пары слово-частота
- 100 002 строка - общее количество введенных пользователем слов
- от 100 003 до конца - вводные слова пользователя
Я разбираю этот файл и помещаю слова в
Dictionary<string,double> dictionary;
И я хочу выполнить некоторую логику поиска + порядка в следующем коде:
for(int i=0;i<15000;i++)
{
tempInputWord = //take data from file(or other sources)
var adviceWords = dictionary
.Where(p => p.Key.StartsWith(searchWord, StringComparison.Ordinal))
.OrderByDescending(ks => ks.Value)
.ThenBy(ks => ks.Key,StringComparer.Ordinal)
.Take(10)
.ToList();
//some output
}
Проблема: Этот код должен выполняться менее чем за 10 секунд.
На моем компьютере (ядро i5 2400, 8 гб RAM) с Parallel.For() - около 91 сек.
Можете ли вы дать мне несколько советов, как повысить производительность?
ОБНОВЛЕНИЕ:
Ура! Мы сделали это!
Спасибо @CodesInChaos, @usr, @T_D и всем, кто принимал участие в решении проблемы.
Конечный код:
var kvList = dictionary.OrderBy(ks => ks.Key, StringComparer.Ordinal).ToList();
var strComparer = new MyStringComparer();
var intComparer = new MyIntComparer();
var kvListSize = kvList.Count;
var allUserWords = new List<string>();
for (int i = 0; i < userWordQuantity; i++)
{
var searchWord = Console.ReadLine();
allUserWords.Add(searchWord);
}
var result = allUserWords
.AsParallel()
.AsOrdered()
.Select(searchWord =>
{
int startIndex = kvList.BinarySearch(new KeyValuePair<string, int>(searchWord, 0), strComparer);
if (startIndex < 0)
startIndex = ~startIndex;
var matches = new List<KeyValuePair<string, int>>();
bool isNotEnd = true;
for (int j = startIndex; j < kvListSize ; j++)
{
isNotEnd = kvList[j].Key.StartsWith(searchWord, StringComparison.Ordinal);
if (isNotEnd) matches.Add(kvList[j]);
else break;
}
matches.Sort(intComparer);
var res = matches.Select(s => s.Key).Take(10).ToList();
return res;
});
foreach (var adviceWords in result)
{
foreach (var adviceWord in adviceWords)
{
Console.WriteLine(adviceWord);
}
Console.WriteLine();
}
6 секунд (9 секунд без ручного цикла (с linq)))
Ответы
Ответ 1
-
Замените словарь на List<KeyValuePair<string, decimal>>
, отсортированный по клавише.
Для поиска я использую, что подстрока сортирует непосредственно перед своими префиксами с порядковыми сравнениями. Поэтому я могу использовать бинарный поиск, чтобы найти первого кандидата. Поскольку кандидаты смежны, я могу заменить Where
на TakeWhile
.
int startIndex = dictionary.BinarySearch(searchWord, comparer);
if(startIndex < 0)
startIndex = ~startIndex;
var adviceWords = dictionary
.Skip(startIndex)
.TakeWhile(p => p.Key.StartsWith(searchWord, StringComparison.Ordinal))
.OrderByDescending(ks => ks.Value)
.ThenBy(ks => ks.Key)
.Select(s => s.Key)
.Take(10).ToList();
-
Обязательно используйте порядковое сравнение для всех операций, включая начальную сортировку, двоичный поиск и проверку StartsWith
.
- Я бы назвал
Console.ReadLine
вне параллельного цикла. Вероятно, используя AsParallel().Select(...)
в наборе слов поиска вместо Parallel.For
.
Ответ 2
Вы совсем не используете какую-либо алгоритмическую силу словаря. В идеале вы должны использовать древовидную структуру, чтобы вы могли выполнять поиск в префиксах. С другой стороны, вы в пределах 3.7x от своей производительности. Я думаю, вы можете достичь этого, просто оптимизируя постоянный коэффициент в вашем алгоритме.
- Не используйте LINQ в первичном критическом коде. Ручная петля по всем коллекциям и сбор результатов в
List<T>
. Это, оказывается, дает значительное ускорение на практике.
- Не используйте словарь вообще. Просто используйте
KeyValuePair<T1, T2>[]
и пропустите его, используя цикл foreach
. Это самый быстрый способ пересечения набора пар.
Может выглядеть так:
KeyValuePair<T1, T2>[] items;
List<KeyValuePair<T1, T2>> matches = new ...(); //Consider pre-sizing this.
//This could be a parallel loop as well.
//Make sure to not synchronize too much on matches.
//If there tend to be few matches a lock will be fine.
foreach (var item in items) {
if (IsMatch(item)) {
matches.Add(item);
}
}
matches.Sort(...); //Sort in-place
return matches.Take(10); //Maybe matches.RemoveRange(10, matches.Count - 10) is better
Это должно превысить ускорение 3,7 раза.
Если вам нужно больше, попробуйте наполнить элементы в словарь, введенный в первый char из Key
. Таким образом, вы можете найти все элементы, соответствующие tempInputWord[0]
. Это должно сократить время поиска за счет селективности, которая находится в первом char of tempInputWord
. Для текста на английском языке это будет порядка 26 или 52. Это примитивная форма поиска в префиксе, которая имеет один уровень поиска. Не очень, но, возможно, этого достаточно.
Ответ 3
Я думаю, что лучшим способом было бы использовать структуру данных Trie вместо словаря. Структура данных Trie сохраняет все слова в древовидной структуре. A node может представлять все слова, начинающиеся с одних и тех же букв. Итак, если вы ищете поисковое слово tempInputWord в Trie, вы получите node, который представляет все слова, начинающиеся с tempInputWord, и вам просто нужно пройти через все дочерние узлы. Таким образом, у вас есть только одна операция поиска. Ссылка на статью в Википедии также упоминает некоторые другие преимущества по сравнению с хэш-таблицами (что в основном словарь):
- Поиск данных в trie быстрее в худшем случае, O (m) время (где m - длина строки поиска), по сравнению с несовершенной хеш-таблица. У несовершенной хеш-таблицы могут быть ключевые коллизии. Ключ collision - это хеш-функция, отображающая различные ключи для одного и того же положение в хеш-таблице. Наихудшая скорость поиска в несовершенном хэш-таблица - это время O (N), но более типично O (1), с O (m) время, потраченное на оценку хэша.
- В trie нет столкновений разных ключей.
- Ведра в trie, которые аналогичны хэш-табличным ковшим, которые хранят ключевые коллизии, необходимы только в том случае, если один ключ связанных с более чем одним значением.
- Нет необходимости предоставлять хеш-функцию или изменять хеш-функции, так как в trie добавлено больше ключей.
- Trie может обеспечить алфавитное упорядочение записей с помощью ключа.
И здесь есть некоторые идеи для создания trie в С#.
Это должно, по крайней мере, ускорить поиск, однако создание Trie может быть медленнее.
Update:
Хорошо, я сам тестировал его, используя файл с частотами английских слов, который использует тот же формат, что и ваш. Это мой код, который использует класс Trie, который вы также пытались использовать.
static void Main(string[] args)
{
Stopwatch sw = new Stopwatch();
sw.Start();
var trie = new Trie<KeyValuePair<string,int>>();
//build trie with your value pairs
var lines = File.ReadLines("en.txt");
foreach(var line in lines.Take(100000))
{
var split = line.Split(' ');
trie.Add(split[0], new KeyValuePair<string,int>(split[0], int.Parse(split[1])));
}
Console.WriteLine("Time needed to read file and build Trie with 100000 words: " + sw.Elapsed);
sw.Reset();
//test with 10000 search words
sw.Start();
foreach (string line in lines.Take(10000))
{
var searchWord = line.Split(' ')[0];
var allPairs = trie.Retrieve(searchWord);
var bestWords = allPairs.OrderByDescending(kv => kv.Value).ThenBy(kv => kv.Key).Select(kv => kv.Key).Take(10);
var output = bestWords.Aggregate("", (s1, s2) => s1 + ", " + s2);
Console.WriteLine(output);
}
Console.WriteLine("Time to process 10000 different searchWords: " + sw.Elapsed);
}
Мои результаты на довольно похожей машине:
Время, необходимое для чтения файла и сборки Trie с 100000 словами: 00: 00: 00.7397839
Время обработки 10000 различных поисковых слов: 00: 00: 03.0181700
Итак, я думаю, что вы делаете что-то неправильно, чего мы не можем видеть. Например, как вы измеряете время или способ чтения файла. Как показывают мои результаты, этот материал должен быть очень быстрым. 3 секунды в основном связаны с выходом Консоли в цикле, который мне нужен, чтобы использовать переменную bestWords. В противном случае переменная была бы оптимизирована.
Ответ 4
Если вы хотите профилировать, отделите чтение файла и посмотрите, сколько времени потребуется.
Также вычисление, сбор, представление могут быть разными.
Если вы хотите совпадение И словарь, посмотрите на ConcurrentDictionary, возможно, даже больше на надежность, чем на производительность, но, вероятно, для обоих:
http://msdn.microsoft.com/en-us/library/dd287191(v=vs.110).aspx
Ответ 5
Предполагая, что значение 10 является постоянным, почему каждый хранит весь набор данных? Память не бесплатна. Самое быстрое решение - сохранить первые 10 записей в списке, отсортировать его. Затем, сохраняя 10-элемент-отсортированный список, когда вы проходите через остальную часть набора данных, удаляя 11-й элемент каждый раз, когда вы вставляете элемент.
Этот метод лучше всего подходит для небольших значений. Если вам нужно было взять первые 5000 объектов, рассмотрите возможность использования двоичной кучи вместо списка.