Рубрики
Без рубрики

Профилирование Java: сравнение BSTS, попыток и тестов

До сих пор в этой серии мы обсуждали попытки и тесты как возможные альтернативы бинарному дереву поиска… Теги: алгоритмы, java, 100daysofcode, информатика.

До сих пор в этой серии мы обсуждали попытки | и TSTs как возможные альтернативы

Но есть ли реальное преимущество в их использовании? И действительно ли тесты позволяют дополнительно экономить на попытках?

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

Ваш комплект довольно прост в использовании и хорошо интегрирован с IntelliJ Idea, но, очевидно, есть также много альтернатив, которые послужили бы моей цели, например, Профиль .

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

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

public class StringsTreeProfiler {
    public static final String WORDS_JSON = "words_dictionary.json";

    @Test
    public void profileTrieMemory() throws FileNotFoundException {
        Trie trie = new Trie();
        Tst tst = new Tst();
        BST bst = new BST<>();

        InputStream inputStream = new FileInputStream(WORDS_JSON);
        try {
            JSONObject json = new JSONObject(new JSONTokener(inputStream));
            for (String key : json.keySet()) {
                trie.add(key);
                tst.add(key);
                bst.add(key);
            }
        } catch (ValidationException e) {
            Assert.fail();
        }
    }

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

Очевидно, что когда мы проводим такой практический тест, нам нужно знать, что существует множество переменных, которые влияют на результаты, начиная с реализаций (например, степень оптимизации кода или варианты, такие как использование java.util. Необязательно , как мы увидим), параметры компилятора, используемые библиотеки, операционная система и архитектура базового оборудования (например, если это 32-разрядный или 64-разрядный процессор).

Все эти детали влияют на конечный результат; имеют ли они значение? Это зависит: очевидно, что когда сравниваемые результаты близки друг к другу, эти факторы более важны, потому что они могут иметь значение. Но, с точки зрения асимптотического анализа, эти варианты в основном влияют на постоянный коэффициент, который умножает время выполнения (если, конечно, нет ошибок реализации, которые приводят к серьезной неэффективности, например, реализация обратного метода, который требует квадратичного времени вместо оптимальной версии с линейным временем).

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

Бинарное Дерево Поиска

Давайте начнем с нашей базовой линии, бинарных деревьев поиска.

Во-первых, давайте посмотрим, сколько оперативной памяти используется в целом:

Затем, что еще более интересно, мы можем сломать эту информацию в классе, и видеть, что мы нужны 1.8 м по британскому летнему времени узлы, и много места был также рассмотрен дополнительный объекты (которые, однако, были временные объекты, используемые только в качестве возвращаемых значений с помощью некоторых методов).

Трие

Теперь, когда у нас есть базовая линия, давайте посмотрим, как идут попытки в сравнении: по-видимому, общая используемая память немного больше, 206 МБ против 172 МБ, используемых бинарными деревьями поиска.

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

В то время как на самом деле создается только вдвое меньше узлов – едва 1 м Trie::Node против 1,8 М По британскому летнему времени::узел – каждый узел дерева должен иметь частности, ,, и общее пространство, требуемое этими структурами составляет более 150мб – почти 75% от общего объема используемой оперативной памяти.

Общие значения, однако, очень близки, поэтому две структуры данных можно считать сопоставимыми по их отпечатку в памяти – фактическая относительная производительность, конечно, будет зависеть от длины и перекрытия сохраненных строк: чем больше они перекрываются, тем лучше будут выполняться попытки по сравнению с BSTS.

Троичное Дерево Поиска

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

Результат потрясающий: им просто нужен 61 МБ оперативной памяти для хранения того же словаря, который требовался три раза с помощью Bats и tries.

Глядя на разбивку по классам, мы видим, что создается примерно такое же количество узлов, как и для попыток, но на этот раз у нас нет бремени HashMap s для хранения.

Tst Случайный Порядок Вставки

Что ж, это было бы убедительно, не так ли? Есть только один тест, который мы могли бы и должны провести. В списке в начале этого поста мы добавляем слова в цикле for-in непосредственно из объекта JSON, созданного из файла.

В каком порядке вставляются ключи? Отсортированы ли они или упорядочены случайным образом? Метод, подобный keySet , не дает никаких гарантий в отношении порядка, используемого для возврата элементов, поэтому я организовал два теста: один со всеми ключами, отсортированными по возрастанию, и один с явно перетасованными ключами, используя List для их хранения и Collections.shuffle .

List keys = new ArrayList<>((new JSONObject(new JSONTokener(inputStream))).keySet());
Collections.shuffle(keys);
for (String key : keys) {
    trie.add(key);
}

Помните, что в то время как четыре попытки порядок вставки не имеет значения, он имеет значение в TSTs (и BSTs), а неблагоприятная последовательность вставок (например, добавление ключей в отсортированной последовательности) может привести к перекошенному спископодобному дереву.

Несмотря на это, количество необходимых узлов остается неизменным, а общий объем необходимой оперативной памяти остается неизменно ниже 70 МБ.

Примечание об использовании дополнительных

Для BSTS вы можете видеть, как много места занимает Необязательно – даже без этого общий объем используемой оперативной памяти был бы намного больше, чем для тестов, но он стал бы ближе.

Необязательный – отличный способ сделать ваш код более чистым, хотя версия java.util имеет некоторые проблемы , ограничивающие ее применимость, и она предназначена не для хранения значений, как в свойстве класса (она даже не сериализуема), а просто как промежуточное звено продукт (особенно и первоначально для методов Stream ).

Другими словами, проецируя это на случай наших древовидных структур данных, замена null на Optional.empty для недостающей ссылки на дочерний элемент нежелательна.

Но, просто ради любопытства, каково было бы влияние этого, обернув ссылки на дочерние элементы в Необязательный ?

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

Теперь у нас есть приличное представление о преимуществе, которое дает Tests с точки зрения оперативной памяти, но это только одна сторона медали! Другим важным фактором, который следует учитывать, является время выполнения наиболее важных операций с этими контейнерами: основные операции, такие как вставка и удаление, а также поиск.

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

Вы можете задаться вопросом, почему этого не происходит с Трие и Тест ? Если вы помните, что мы обсуждали в части 2 этой серии, важное отличие состоит в том, что с бинарными деревьями поиска высота дерева зависит от количества сохраненных элементов – логарифмически, если дерево сбалансировано, или линейно в худшем случае (что происходит именно тогда, когда входные данные добавляются в отсортированная последовательность). Для попыток и тестов, вместо этого, высота дерева зависит только от длины добавленных слов и, только для тестов, также от размера алфавита: в худшем случае мы, следовательно, говорим о разнице порядков величин: n is порядка 370 тыс. элементов, в то время как сумма длины самого длинного слова и размера алфавита не достигает 50.

Вставка

Что касается основных методов, мы сосредоточимся на тестировании вставки; Мы будем повторно использовать код, вставляя все ключи из английского словаря в контейнер, и проверять производительность в двух случаях: когда список ключей перемешивается и когда он сортируется лексикографически.

@Test
public void profileRunningTime() throws FileNotFoundException {
    Trie trie  = new Trie();
    Tst tst = new Tst();
    BST bst = new BST<>();

    InputStream inputStream = new FileInputStream(WORDS_JSON);
    try {
        List keys = new ArrayList<>((new JSONObject(new JSONTokener(inputStream))).keySet());
//            Collections.shuffle(keys);
        Collections.sort(keys);
        for (String key : keys) {
            trie.add(key);
            tst.add(key);
//                bst.add(key);
        }
    } catch (ValidationException e) {
        Assert.fail();
    }
}

Перетасованный список

Что происходит, когда мы измеряем общее время, необходимое для заполнения контейнеров всеми словами в словаре? Не удивительно, как мы предлагали в первой части этой серии, Бсц медленнее: он принимает 1.28 секунды, чтобы добавить весь словарь, чтобы по британскому летнему времени.

Нах гораздо лучше, вся операция заняла всего 0,51 секунд, менее чем за половину времени, необходимого для по британскому летнему времени.

Нах гораздо лучше, вся операция заняла всего 0,51 секунд, менее чем за половину времени, необходимого для по британскому летнему времени. Нах гораздо лучше, вся операция заняла всего 0,51 секунд, менее чем за половину времени, необходимого для по британскому летнему времени.

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

Отсортированный список

Но, как мы уже говорили, это наилучший сценарий для ТЕСТА, потому что при случайной последовательности ввода результирующее дерево будет в среднем сбалансировано (при добавлении 300 тысяч слов шансы получить последовательность ввода в худшем случае довольно мрачны …).

В этом случае мы могли тестировать попытки только против TST, потому что Bats, которые в любом случае были бы ужасно медленными, столкнулись с переполнением стека.

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

Когда входные данные отсортированы, шины (которые не чувствительны к порядку вставки) показали лучшие результаты, чем тесты.

Самый Длинный Общий Префикс

Последним шагом для этого поста является проверка того, как эти три структуры работают при поиске. Чтобы проверить это, я решил протестировать метод самый Длинный Префикс это, учитывая строку s , проверяет, какой самый длинный префикс s хранится в контейнере.

Метод тестирования создаст контейнеры, а затем вызовет самый длинный префикс миллион раз, используя случайно выбранные реальные слова, фактически хранящиеся в контейнерах, к которым добавляется переменное количество случайных символов.

@Test
public void profileRunningTime() throws FileNotFoundException {
    Trie trie  = new Trie();
    Tst tst = new Tst();

    InputStream inputStream = new FileInputStream(WORDS_JSON);
    try {
        List keys = new ArrayList<>((new JSONObject(new JSONTokener(inputStream))).keySet());
//            Collections.shuffle(keys);
        Collections.sort(keys);
        for (String key : keys) {
            trie.add(key);
            tst.add(key);
        }
        for (int i = 0; i < 1000000; i++) {
            String key = keys.get(random.nextInt(keys.size())) + randomKey(0, 10);
            trie.longestPrefixOf(key);
            tst.longestPrefixOf(key);
        }

    } catch (ValidationException e) {
        Assert.fail();
    }
}

private String randomKey(int minLength, int maxLength) {
    int length = (int) Math.floor(Math.random() * (maxLength - minLength)) + minLength;
    char[] chars = new char[length];
    for (int i = 0; i < length; i++) {
        chars[i] = (char)(random.nextInt(26) + 'a');
    }
    return new String(chars);
}

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

Вместо этого, когда ТЕСТ искажен, trie работает намного лучше (примерно в два раза быстрее).

Профилирование помогло нам понять преимущества и недостатки использования различных контейнеров строк в реальном сценарии. Мы смогли доказать, что усилия по внедрению и поддержанию специальную структуру данных (как истинный или) компенсируется большим экономия пространства (вплоть до 20% от рам, используемых по британскому летнему времени) и время выполнения.

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

Оригинал: “https://dev.to/mlarocca/java-profiling-comparing-bsts-tries-and-tsts-2nga”