Блог пользователя RodionGork

Автор RodionGork, 10 лет назад, По-русски

Есть известная задача для новичков которые только научились алгоритму поиска минимума в массиве: найти M минимальных в массиве из N элементов.

И вот коллега недавно проводил собеседование и троллил соискателя вариацией:

Есть десятки терабайт логов — обозначим их как N записей — каждая запись содержит в частности значение некоторого параметра A. Мы хотим выбрать небольшое количество M (ну скажем, миллион) записей в которых значение параметра минимально. Как это сделать?

Характерные нюансы вариации:

  • исходные данные живут во внешней памяти, в оперативку их целиком не прогрузишь;
  • очень желательно чтобы алгоритм можно было распараллелить т.к. N определяемое терабайтами близко по порядку к 1e12 и один проход на обычном компьютере будет занимать примерно сутки (исходный массив хранится в распределённой системе так что нет проблем с тем чтобы использовать его пофрагментно).

Собеседуемого мой коллега успешно затроллил и выгнал с позором, но потом я взялся троллить его самого — и его собственное решение ему нравиться перестало.

Какие возможны подходы к этой задаче?

У меня родилось немного "детских" версий:

  1. Можно идти по нашим данным и пихать их в двоичную кучу с максимальным размером M (т.е. пропускаем элемент если его параметр больше чем максимальный в куче, а если в кучу что-то запихнули и размер стал M+1 то вытряхнем из неё максимальный элемент). Быстродействие здесь будет лучше чем N*log(M) потому что не придётся обычно пихать все элементы — даже вероятно ближе к N + M*log(M). Однако как распараллелить двоичную кучу? Возможно заменить её на ConcurrentSkipListMap...

  2. Если само M таково, что не помещается в оперативку (ещё вариация предложенная коллегой), то вместо кучи мы будем складывать элементы в список-копилку и после достижения размера 2M будем её сортировать слияниями и обрезать размер до M. Быстродействие по-видимому N*log(M). Распараллеливать наверное не очень легко но можно.

  3. Больше всего мне нравится вариант (против которого коллега протестует) стохастический: пройдём по исходным данным и выберем все записи значение параметра у которых ниже некоего порога k. Как определим этот порог? Тут разные варианты — например сделаем предварительный проход по данным и выберем рандомно столько значений параметра сколько влезает в оперативку, скажем миллиард. Возьмём от них пропорциональное M/N количество наименьших значений и в качестве порога k выберем максимальное из этих значений. Конечно в результате просеивания мы потом можем получить не совсем M записей — но мы можем либо заранее завысить порог, чтобы прицелиться на 2M записей например — и отбросить лишнее. Т.е. этот способ хорош "среднестатистически". Его сложность может быть близка к O(N) и распараллелить его вроде легко.

Но хотелось бы узнать о более остроумных вариантах — если есть какая-то удобная структура со свойствами очереди с приоритетами, которую можно распараллеливать (а то ещё держать во внешней памяти) — то первый вариант можно довести до ума?

  • Проголосовать: нравится
  • +4
  • Проголосовать: не нравится

»
10 лет назад, # |
Rev. 2   Проголосовать: нравится +5 Проголосовать: не нравится

Есть алгоритм нахождения K-ой порядковой статистики за O(N) в наихудшем случае. Делается на основе qsort с разбиением на 5 массивов, в принципе, можно его изменить и разбить на большее количество.

Если элементы во входных логах не повторяются, то найдя m-ый по возрастанию за O(N) потом идем и кладем все что меньше или равны. Если элементы повторяются, то сначала проходим и берем все что меньше, потом выбираем необходимое кол-во равных.

Алгоритм поиска должен неплохо паралелиться, т.к. он рекурсивный. Хотя тут важно какую часть мы еще можем поместить в оперативку. Описание алгоритма есть в Кормене.

  • »
    »
    10 лет назад, # ^ |
    Rev. 3   Проголосовать: нравится +5 Проголосовать: не нравится

    Дело в том, что алгоритм K-ой порядковой статистики работает на памяти с произвольным доступом, а не на диске с терабайтом данных.
    Если считать, что у нас M влазит в память, то да, идём по массиву, заполняем буфер до 2 * M, обрезаем первые M: std::nth_element(v.begin(), v.begin() + M, v.end()); Параллелится тем, что можно разбить массив на K частей и каждую обрабатывать своим потоком. O(N)

    • »
      »
      »
      10 лет назад, # ^ |
      Rev. 6   Проголосовать: нравится +3 Проголосовать: не нравится

      Если же M в память не влазит, а влазит только 2K элементов (2K < M), то нужно использовать что-то вроде дисковой сортировки, но не сортировать. Мерж двух кусков по 2K элементов такой: делаем первому и второму nth_element(a, a + k, a + 2*k) пусть a[k] > b[k], тогда for(i = 0;i < k;++i) swap(b[i + k], a[i + k]); снова nth_element(b, b + k, b + 2*k) теперь все элементы первых половин массивов больше всех оставшихся элементов. Будет что-то вроде O(N * log(M/K))

      • »
        »
        »
        »
        10 лет назад, # ^ |
          Проголосовать: нравится +5 Проголосовать: не нравится

        можно написать обычную сортировку слиянием, как в GNU coreutils, но при каждом слиянии оставлять только первые M элементов

        • »
          »
          »
          »
          »
          10 лет назад, # ^ |
            Проголосовать: нравится 0 Проголосовать: не нравится

          Но последниее слияние обрабатывающее N элементов мы не можем распараллелить?

          • »
            »
            »
            »
            »
            »
            10 лет назад, # ^ |
              Проголосовать: нравится 0 Проголосовать: не нравится

            ни одно слияние не будет обрабатывать больше M элементов

            • »
              »
              »
              »
              »
              »
              »
              10 лет назад, # ^ |
                Проголосовать: нравится 0 Проголосовать: не нравится

              А ну да, я двоечник фразу не дочитал до конца.

      • »
        »
        »
        »
        10 лет назад, # ^ |
        Rev. 2   Проголосовать: нравится 0 Проголосовать: не нравится

        а не, тут я перемудрил, тут степень log2(3) появляется. F(n) = 3*F(n / 2) + O(n). Тупо сортировка слиянием лучше выходит.

»
10 лет назад, # |
  Проголосовать: нравится +8 Проголосовать: не нравится

На прошлом московском четвертьфинале была задача: есть определенные правила генерации последовательности, сгенерируйте ее на своем компьютере и выберите K (K ≤ 105) максимальных элементов. Количество элементов последовательности было довольно большим. Ограничение на память было 8МБ. Предлагалось два решения. Первое — тупо идти по последовательности, держа при себе не более 2K элементов, время от времени пересортировывая их каким-то хитрым способом (схоже с подходами 1 и 2). Второй подход — разобьем каждое число на два блока по 16 битов, и при первом проходе запомним, сколько чисел попадает в какой блок (который первым идет). На втором проходе мы уже будем знать, какие первые блоки могут быть у этих минимальных чисел. Вроде это выглядит довольно ограниченным по памяти и даже линейной сложности.

На контесте я пытался заслать что-то наподобие третьего подхода, но ввиду кривых рук даже не отдебагал:( Да и вряд ли зашло бы.

  • »
    »
    10 лет назад, # ^ |
      Проголосовать: нравится +8 Проголосовать: не нравится

    Второе сложности N*d, где d — кол-во разрядов в числе. Обычно d ~ lg(N).

    • »
      »
      »
      10 лет назад, # ^ |
      Rev. 3   Проголосовать: нравится +8 Проголосовать: не нравится

      Почему? Мы же для каждого числа узнаем блок, в котором он находится за о(1),так же за о(1) мы узнаем и последние 16 битов числа. Посчитали блоки, что гарантировано входят в ответ за о(log(n)), а потом одним пробегом добрали "недостающие" числа. Не тяжело понять, что все "недостающие" числа лежат в одном блоке.

      • »
        »
        »
        »
        10 лет назад, # ^ |
          Проголосовать: нравится +8 Проголосовать: не нравится

        Потому же, почему и сортировка подсчетом за O(N*d), представь что у тебя числа d битные, тогда решение за N*d/64.

  • »
    »
    10 лет назад, # ^ |
    Rev. 2   Проголосовать: нравится 0 Проголосовать: не нравится

    Я недавно наткнулся (на курсере) на описание задачи (вроде поиска пресловутых "памперсов и пива"), где наиболее частые кортежи по бигдате искали — и там подобный рандомный вариант очень восхваляли. Т.е. похоже в реальности им пользуются не очень парясь что можно часть результатов потерять.

»
10 лет назад, # |
Rev. 3   Проголосовать: нравится +11 Проголосовать: не нравится

Непонятно почему, если мы уже согласились на N × log(M), то просто не сделать бинпоиск по значению. Вроде получается невероятно простой и легко параллелящийся код.
Опять же, каждую машину можно сделать ответственной за свой кусок логов и вытащить их один раз.
самофикс: будет, конечно же, N × log(maxA)

»
10 лет назад, # |
  Проголосовать: нравится +13 Проголосовать: не нравится

В формулировке не хватает многих важных деталей. Задача решается на одной машине, или на нескольких? Насколько быстрый storage (может, это ssd)? Если машин несколько, насколько быстрая сеть? Рандомно ли разбиты логи по машинам?

На одной машине ничего лучше прохода с кучей не придумаешь — bottleneck всё равно диск. Если машин несколько (k штук) — то для рандомного разбиения можно достать c * m / k минимальных элементов с каждой машинки, а потом посортить их на мастере. Если про рандомность ничего не известно, то можно либо доставать по m элементов с машинки (а потом жёсткий file sort m*k элементов), либо сделать 2 прохода — на первом с каждой машинки достать порядковые статистики , на мастере их посортить, понять, где находится ответ, и на второй фазе достать по элементов с машинки. Итого — 2 прохода и 2 sort'а элементов (при этом такой подход отлично реализуется mapreduce'ом). В общем, всё зависит от требований.

  • »
    »
    10 лет назад, # ^ |
      Проголосовать: нравится 0 Проголосовать: не нравится

    В формулировке не хватает многих важных деталей. Задача решается на одной машине, или на нескольких? Насколько быстрый storage (может, это ssd)? Если машин несколько, насколько быстрая сеть? Рандомно ли разбиты логи по машинам?

    Я действительно про распараллеливание нечётко слишком сказал.

    Можно считать что задача решается в реалиях хадупа — несколькими десятками виртуальных или реальных машинами, а сторадж это файлы по 100Мб в HDFS, размещённые несколькими копиями на этих машинах в произвольном порядке — т.е. да, в жизни решение именно в виде мэп-редьюса работает

    либо сделать 2 прохода — на первом с каждой машинки достать порядковые статистики

    да, спасибо, кажется прикольно придумано — хоть я и медленно корректность этого подхода осознаю. :)

  • »
    »
    10 лет назад, # ^ |
      Проголосовать: нравится -8 Проголосовать: не нравится

    На одной машине можно применять nth_element(a, a + m, a + 2 * m); линия всяко лучше прохода с кучей.

    • »
      »
      »
      10 лет назад, # ^ |
        Проголосовать: нравится +19 Проголосовать: не нравится

      Это только если данные на одной машине влезают в память. Что далеко не всегда правда.