Пожалуйста, прочтите новое правило об ограничении использования AI-инструментов. ×

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

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

Всем привет. Сегодня я расскажу о своих приключениях с сортировкой массивов Scala, а также о найденном подводном камне этих сортировок, а также о том, почему для сортировки массивов элементарных типов все еще стоит использовать старый добрый java.util.Arrays.sort вместо scala.util.Sorting.quickSort.

История

Все началось с этой задачи (ссылка на разбор там есть). Для ее решения необходима сортировка массива Long-ов. Первый способ, который приходит в голову — это использовать scala.util.Sorting.quickSort. Моя посылка. Вердикт — TLE. Из этой посылки нас будут интересовать в дальнейшем только две строки:

    Sorting.quickSort(array)
    Sorting.quickSort(b)

Я был немного удивлен таким результатом. Но что поделать, пишу свою собственную сортировку, merge (люблю ее за то, что под нее, в отличии от квиксорта, нельзя подобрать плохой тест). Посылка. Нас из нее интересует это:

  def merge(a: Array[Long], b: Array[Long], l: Int, r:Int, m:Int) {
    var al_next = l
    var am_next = m + 1
    for (i <- l to r)
      if (am_next > r || (al_next <= m && a(al_next) < a(am_next))) {
        b(i) = a(al_next)
        al_next += 1
      }
      else {
        b(i) = a(am_next)
        am_next += 1
      }
  }
  def mergeSortAct(a: Array[Long], b: Array[Long], l: Int, r: Int) {
    if (l >= r)
      return
    val m = (l + r) / 2
    mergeSortAct(a, b, l, m)
    mergeSortAct(a, b, m+1, r)
    merge(a, b, l, r, m)
    Array.copy(b, l, a, l, r - l + 1)
  }
  def mergeSort(a: Array[Long]) {
    mergeSortAct(a, Array.ofDim[Long](a.length), 0, a.length - 1)
  }

И

    mergeSort(array)
    mergeSort(b)

Результат — полное решение. Хорошо, но я все же удивлен тем, что встроенная сортировка так сильно сливает. Идем дальше: пробуем сортировку из java (Scala ведь позволяет использовать любые Java-классы). Используем java.util.Arrays.sort. Посылка. Сортировка:

    Arrays.sort(array)
    Arrays.sort(b)

Результат — полное решение.

Гугление подсказало, что в Scala, помимо Sorting.quickSort, есть еще Sorting.stableSort, который реализован сортировкой слиянием. Пробую использовать. Попытка. ВНЕЗАПНО TLE#7, т.е., их сортировка слиянием работает хуже моей. Это меня очень заинтересовало, поэтому я отыскал соответствующий код (специально взял код из версии 2.9.0, как на codeforces, хотя в последующих версиях конкретно этот участок кода почти не менялся, единственное изменение — ClassManifest на ClassTag). Вот, собственно, функция, которая делает работу (все перегруженные версии stableSort в конечном итоге вызывают ее):

  private def stableSort[K : ClassTag](a: Array[K], lo: Int, hi: Int, scratch: Array[K], f: (K,K) => Boolean) {
    if (lo < hi) {
      val mid = (lo+hi) / 2
      stableSort(a, lo, mid, scratch, f)
      stableSort(a, mid+1, hi, scratch, f)
      var k, t_lo = lo
      var t_hi = mid + 1
      while (k <= hi) {
        if ((t_lo <= mid) && ((t_hi > hi) || (!f(a(t_hi), a(t_lo))))) {
          scratch(k) = a(t_lo)
          t_lo += 1
        } else {
          scratch(k) = a(t_hi)
          t_hi += 1
        }
        k += 1
      }
      k = lo
      while (k <= hi) {
        a(k) = scratch(k)
        k += 1
      }
    }
  }

Практически 1-в-1 с моей. Я было даже подумал "а вдруг у КФ какая-то неправильная Scala с другой реализацией сортировки" и перекопипастил это в мой код и отправил (естественно, TLE, ибо никакого заговора на самом деле нет). Тогда я стал анализировать отличия кода от моего. Вот:

  • имена переменных
  • слияние — в той же функции, что и сама сортировка
  • они везде используют циклы while, а я — for
  • я использую Array.copy, а они копируют циклом
  • моя сортировка заточена под Long, а их — универсальна и работает для всего
  • моя сортировка сравнивает элементы оператором <, а их — используя функцию, передаваемую в качестве аргумента

Естественно, первые три — это по большей мере косметическая разница, никакой реальной роли они не играют. Четвертое в теории могло бы играть какую-либо роль, но я попробовал сделать в своей реализации простое копирование циклом, и она все равно нормально проходила, заметного увеличения времени не было. А вот 5 и 6 — это и есть то самое место, где собака зарыта. Если взять функцию stableSort, заточить ее под Long и использовать обычные сравнения вместо функции, передаваемой аргументом, то она проходит все тесты. Вот так вот выглядит мой вариант этой функции:

  private def scalaMergeSort(a: Array[Long], lo: Int, hi: Int, scratch: Array[Long]) {
    if (lo < hi) {
      val mid = (lo+hi) / 2
      scalaMergeSort(a, lo, mid, scratch)
      scalaMergeSort(a, mid+1, hi, scratch)
      var k, t_lo = lo
      var t_hi = mid + 1
      while (k <= hi) {
        if ((t_lo <= mid) && ((t_hi > hi) || (a(t_lo) >= a(t_hi)))) {
          scratch(k) = a(t_lo)
          t_lo += 1
        } else {
          scratch(k) = a(t_hi)
          t_hi += 1
        }
        k += 1
      }
      k = lo
      while (k <= hi) {
        a(k) = scratch(k)
        k += 1
      }
    }
  }

Вот так вот. В java.util.Arrays мы имеем функции для сортировки массивов всех примитивных типов (разработчики вынуждены это делать, т.к. в Java нет способа написать универсальную функцию для сортировки всего). В Scala же такая возможность есть, и разработчики ею воспользовались. quickSort имеет отдельные реализации для Float, Double, Int, но не для Long. stableSort же один для всех типов. Такая универсальность требует жертв: в моем случае накладные расходы на вызов функции сравнения оказались слишком велики.

Выводы

  1. Если у вас Array[Long], и критерий сортирования — за неубыванием или невозрастанием, то сортируйте его при помощи java.util.Arrays.sort, либо своей собственной процедурой. Для Array[Int], Array[Float], Array[Double] специальные реализации scala.util.Sorting.quickSort есть, но после этого случая я бы побоялся на них полагаться.
  2. (капитан очевидность) Не стоит пренебрегать расходами на вызов функций. Это не C++, тут нет плюсовых компайл-тайм шаблонов и спасающих инлайнов.
  • Проголосовать: нравится
  • +45
  • Проголосовать: не нравится

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

На Codeforces-то и java.util.Arrays.sort небезопасно применять...

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

Спасибо, за хорошее исследование, сам иногда сдаю на Scala — будет полезно это знать.

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

Насчет второго пункта: накладные расходы на вызов функции у JVM настолько огромны, что я иногда даже не пишу отдельные функции для скалярного и векторного произведения.

Как мы знаем, в языках, компилируемых в машинный код, вызов функции превращается в несложную и быстро исполняемую последовательность манипуляций с системным стеком. В Java же сначала вызывается т.н. method stub, из которого происходит уже переход на скомпилированный машинный код. Перед этим выполняется проверка "а существует ли он вообще?", если нет, то выполняется его компиляция; виртуальная машина может в фоновом режиме выполнять оптимизацию уже скомпилированных методов. Разумеется, все это должно быть как-то синхронизировано с исполнением пользовательского кода — вот вам еще дополнительные накладные расходы.

Кстати, в том же дотнете, насколько мне известно, все вышеперечисленное может быть оптимизировано почти до уровня прекомпилированного кода (вплоть до выполнения inline-подстановок). Не понимаю, почему это все не реализовали для жабы :(.

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

    Инлайн вызовов виртуальных функций в Java HotSpot делается (и еще много чего делается), но только в режиме Server. На CF, кажется, используется Client.

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

      Inline есть и в клиентской версии, только JIT не гарантирует, что ваш код он успеет соптимизировать за один запуск (да и вообще стоило бы использовать оптимизированную серверную JVM). Его эвристики требуют сравнимо большое количество повторов, чтобы метод был скомпилирован и оптимизирован :-) Ну и вообще всё это бессмысленно, когда warmup'а перед замером времени для Java не делается. В связи с этим печально, что AOT компиляторы для плюсов дают априори более оптимизированный код, а Java будет курить в сторонке со своим интерпретатором.

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

+1

Можно использовать простой a.sorted (который под капотом вызывает тот же самый java.util.Arrays.sort), но нужно иметь в виду то, что a.sorted в процессе создаст 2 дополнительных массива.

Ещё на Скале (2.9, которая на CodeForces) следует избегать TreeSet / TreeMap, так как многие операции не оптимизированы (т.е. О(n)). На Скале 2.10 scala.collection.immutable.TreeSet / TreeMap оптимизированы до О(log n) где ето возможно. А версия mutable.* всё ещё тормозит...

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

    В 2.10.2 мутабельная версия TreeSet использует AVL-деревья, которые гарантирует сложность O(logN) для вставки, удаления, поиска, взятия минимального и максимального элементов. https://github.com/scala/scala/blob/v2.10.2/src/library/scala/collection/mutable/TreeSet.scala#L1

    В ветке 2.9 же нет такого файла вообще. Он появился в ветке 2.10, и был внесен этим коммитом:

    commit 51ddeb372b3f0b22041d9a51f3faee17acd7b749
    Author: aleksandar <[email protected]>
    Date:   Thu Jan 12 15:28:25 2012 +0100
    
        Add mutable tree sets to the standard library.
        
        This implementation is based on AVL trees.
        The current implementation is contributed by Lucien Pereira.
        
        Fixes #4147.
    

    Т.е., AVL-дерево использовалось с самого начала в мутабельном TreeSet.

    Иммутабельная версия и в 2.9, и в 2.10 использует красно-черное дерево, у которого тоже сложность базовых операций O(logN), однако там между 2.9 и 2.10.2 действительно были коммиты, в которых шла речь о повышении быстродействия некоторых операций.

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

Рома, спасибо за анализ, я сейчас как раз назад вернулся к Scala и вспомнил про твой пост. Так вот, хочу добавить, что существует такой метод, как sorted (Scala 2.8+), и он нормально работает с аналогами встроенных типов Java. Кроме того, лучше работать с immutable-методами (а sorted как раз таким и есть) и структурами данных.

В функциональном виде все последнюю часть твоего решения можно было бы заменить на

    val b = d.tail.view.init.scanLeft(d.head) (_ + _)
    println((array.sorted, b.sorted).zipped map (_ * _) sum)
»
11 лет назад, # |
Rev. 3   Проголосовать: нравится 0 Проголосовать: не нравится

Тю, из-за английского языка в системе не увидел что комментарий уже отправился и переотправил. Еще и русские комменты не было видно и я не заметил что про sorted уже написали.