Задача о m максимумах
Выкладываю мою статью на хабре "Задача о m максимумах". Ее там заминусовали, но, возможно, она кому-то покажется интересной... Старая-старая школьно-студенческая задача... Дан массив целых чисел. Требуется найти m его максимальных (или минимальных) элементов. Когда я задаю эту задачу учащимся, то почти в каждой группе находятся "умельцы", решающие ее с помощью сортировки. В самом деле, это ведь так просто: сортируем массив по убыванию (вручную или подходящей библиотечной функцией) и просто берем m первых элементов. Конечно, иначе как алгоритмическим варварством такой подход не назовешь - найти m максимумов достаточно просто за один проход массива; сортировка существенно сложнее. И однажды я решил исследовать сей вопрос вычислительными экспериментами. Результаты этих экспериментов я и предлагаю вашему вниманию. Не исключено, что кому-то результаты помогут и в практической работе. Намекну - интуиция меня в целом не подвела. Без сортировки задача может быть решена, например, так. Создаем рабочий массив длины m и заполняем его начальными значениями. В общем случае можно в качестве такого значения выбрать минимальное значение int/integer для соответствующей среды программирования. А если известна нижняя граница значений исходного массива, то можно взять любое число, меньшее этой границы. Итак, рабочий массив заполнен одинаковыми значениями. Теперь берем элемент за элементом исходного массива и вставляем его в нужное место рабочего массива. При этом длину рабочего массива сохраняем равной m (после вставки последний элемент пропадает). Если очередной элемент меньше последнего значения рабочего массива, то он просто пропускается. Этот процесс имеет вычислительную сложность O(nm). Тогда как сортировка в лучшем случае описывается асимптотикой O(n*og(n)). Асимптотики показывают, как ведет себя функция (в данном случае - время сортировки) при стремлении параметров к бесконечности. Можно сказать, что время описанного алгоритма при стремлении n к бесконечности задается формулой t1=k1*O(n), а время сортировки t2=k2*O(n*log(n)). Здесь k1 и k2 - некие константы, зависящие от процессора, языка программирования, операционной системы и других факторов. Я построил три системы тестов (для Питона, Java и VBA). Все тесты устроены по сути одинаково: строились массивы возрастающих размеров, заполнялись случайными числами задаваемого диапазона, сортировались с фиксацией времени и прогонялись через описанный выше алгоритм тоже с фиксацией времени. Каждый тест повторялся 10 раз и время усреднялось. В Питоне и Java использовалась встроенная сортировка, в VBA - реализация QuickSort. Питон Ниже показан код питоновских тестов.
Время здесь приведено в миллисекундах. Что видим? Сортировка отстает (на краю интервала - вдвое). Для пяти максимумов картина аналогична. И надо заметить, что хотя питоновская сортировка очень эффективна, простой алгоритм оказывается быстрее. Заметны резкие провалы в производительности (зубцы на графиках). Они, вероятно, объясняются влиянием внутренних процессов (типа сборки мусора). Это же замечание относится и к другим графикам. Java Код тестов выглядел так:
VBA В этом языке нет универсальной встроенной сортировки (можно, правда, сортировать ячейки листа, но в этом случае будут велики накладные расходы, связанные с загрузкой и выгрузкой данных). Поэтому пришлось реализовать QuickSort вручную. Все это выглядит так:
Представляет некоторый интерес изучить зависимость времени от количества искомых максимумов (при фиксированном размере массива). Здесь, очевидно, сортировка будет тем выгоднее, чем больше максимумов требуется найти. А вставка в упорядоченный массив будет тем медленнее, чем массив больше. Самым невыгодным случаем будет являться, как ни странно, входной массив, уже упорядоченный по возрастанию. В этом случае количество сравнений при вставке достигнет максимума и будет равно n*m. Массив, упорядоченный по убыванию, напротив, весьма выгоден. Число сравнений здесь будет ~ m+n. Описанный в самом начале статьи алгоритм, можно ускорить, если вместо простого упорядоченного массива использовать древовидную или пирамидальную структуру. Именно так и реализована в Питоне в модуле heapq функция nsmallest. Для небольших массивов (несколько тысяч элементов и менее) разница в подходах представляется несущественной. И если нужно быстро написать код, то сортировка - вполне приемлемое решение. Для больших массивов выгоднее от сортировки отказаться. Вот и все, что я хотел рассказать об этой задаче. Спасибо, что дочитали до конца. С наступившим 2021-м годом! ссылка на статью |
Всего комментариев 53
Комментарии
-
Запись от Почтальон размещена 06.01.2021 в 13:17 -
Запись от Catstail размещена 06.01.2021 в 14:24 -
Запись от vantfiles размещена 07.01.2021 в 12:47 -
Catstail, вот бы ещё посмотреть вариант на haskell, на списках.
Цифровой рекурсивный ФНЧ первого порядка. Переменные: текущее и предыдущее выходное значения.Запись от Curry размещена 07.01.2021 в 13:10
Обновил(-а) Curry 07.01.2021 в 13:35 -
Уважаемый Catstail,
задача конечно интересная, но она решается вами в предположении, что если массив содержит более m равных максимальных элементов, то они не различаются друг от друга. Иными словами, если все элементы массива равны, то какие m элементов следует выбирать?
примечание
прошу меня извинить, как математик я не могу пройти мимо некорректно заданной задачи.Запись от wer1 размещена 09.01.2021 в 13:37 -
- если все элементы массива одинаковы, то мои реализации и выдадут m одинаковых значений. Тривиальный и, по-моему, не интересный случай. Главный "пафос" заметки - борьба с тупым использованием стандартных методов.
Запись от Catstail размещена 10.01.2021 в 19:53
Обновил(-а) Catstail 25.01.2021 в 17:41 -
Запись от vantfiles размещена 11.01.2021 в 15:21 -
Запись от Curry размещена 11.01.2021 в 15:45 -
Уважаемый Curry,
что вы имеете в виду говоря о скользящем среднем? Это случайно не вычисление медианы? Её тоже можно вычислить сортировкой массива. Но об использовании двух переменных я не слыхал.Запись от wer1 размещена 12.01.2021 в 09:15 -
Цитата:что вы имеете в виду говоря о скользящем среднем?
Допустим, что то периодически замеряется (ток, напряжение, или стоимость акции, не важно). В обсуждаемую функцию поступает новое замеренное значение и предыдущее состояние. Функция возвращает изменённое состояние для использования в последующем и выходное значение. Если в терминах ООП, то состояние в объекте, а мы обращаемся к методу, передаём очередное значение и получаем выходное, при этом меняя состояние объекта. Обрабатывать последующие значения мы не можем - их просто ещё не существует.
И, вот, как в таких условиях, сделать так, чтобы на выходе выдавалось каждый раз среднее значение последних N отсчётов?
Сохраняя последние N отчётов в состоянии (объекта), это сделать тривиально. А как это сделать имея состояние из двух переменных (очевидно, не массивов), не знаю.Запись от Curry размещена 12.01.2021 в 17:38 -
Уважаемый Curry,
предложенная вами задача неразрешима. Дело в том, чтобы вычислять каждый раз новое среднее значение из N чисел, необходимо удалить одно самое старое (самое первое) значение. И потом производить вычисления с новым числом. А это означает, что вам придётся всё время хранить массив из N чисел (который будет меняться естественно)
Если бы среднее значение вычислялось по всем числам, то да можно обойтись всего двумя переменными.
Вот как к примеру вычисляется среднее арифметическое. Пусть SA - среднее арифметическое от N элементов
и пусть А - новый (N + 1)-ый элемент. Новое значение среднего арифметического будет таким
SA = (SA * N + A) / (N + 1)
Стоит отметить, что некоторые простые попытки поместить несколько чисел в одну переменную всё-же имели успех.
1. например два целых числа A и B можно записать как дробное A,B (100 и 55 => 100,55)
2. цифры также можно записать в виде одного длинного целого числа
3. двузначные числа можно записать парами в одно числоЗапись от wer1 размещена 13.01.2021 в 09:30
Обновил(-а) wer1 13.01.2021 в 09:31 -
Цитата:
но можно упростить задание и добиться подобия результата с некой трудно измеряемой погрешностью, добавив третье число (константу) которая будет содержать в себе количество измерений N
пусть сумма ранее измеренных значений определяется по формуле:
M = m1 + m2 + ... + m(n-1) + mn
тогда новое средние, при поступление нового значения m(n+1) определяется по формуле:
M1 = M0 - M0 / N + m(n+1)Запись от K_ILYA_V размещена 21.01.2021 в 00:10 -
Угу. Я тоже так думаю. Если к примеру все значения массива ограничены сверху и снизу, то погрешность будет равна среднему арифметическому этих крайних значений. Если и далее новые значения не будут выходить за вычисленные нами границы, то действительно новое значение среднего арифметического можно вычислить вычтя из общей суммы всех элементов старое среднее арифметическое и добавив новое слагаемое. А потом разделив на общее число элеменов (ведь оно осталось неизменным). А погрешность тоже вещь постоянная, будучи вычисленная один раз, она не изменится до тех пор, пока новый элемент не выйдет за заданные границы.
Запись от wer1 размещена 21.01.2021 в 10:09 -
Ваша задача может быть решена посредством 13 ассемблерных инструкций, вот код который решает вашу задачу за минимально возможное время.
Этот код прямой как рельса, не имеет ветвлений и несомненно самый быстрый из предложенных.Assembler 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
.686 .model flat, stdcall option casemap :none .data indata dd 1,2,3,4,5,6,7,8, \ 1,2,3,4,5,6,7,8, \ 1,2,3,4,5,6,7,8, \ 1,2,3,4,5,6,7,8, \ 1,2,3,4,5,6,7,8, \ 1,2,3,4,5,6,7,8 sizedt dd (sizedt - indata) / dword N = 30h maxN dd N outdata dd N dup (0) .code start: ; симуляция вызова процедуры push offset indata ; 8 push sizedt ; 7 push maxN ; 6 push offset outdata ; 5 call not_sort ; 4 ret 10h ; инициализация процедуры not_sort: push esi ; 3 push edi ; 2 push ebp ; 1 push ebx ; 0 mov edi,[esp + dword * 5] mov ecx,[esp + dword * 6] mov ebp,[esp + dword * 7] mov esi,[esp + dword * 8] xor eax, eax rep stosd mov ecx,[esp + dword * 6] mov edi,[esp + dword * 5] ; решение поставленной задачи next: lodsd @@: cmp eax,[edi] cmovnb edx,[edi] cmovb eax,[edi] stosd cmovb edx,[esi - dword] mov eax, edx dec ecx jnz @b mov ecx,[esp + dword * 6] mov edi,[esp + dword * 5] dec ebp jnz next ; завершение процедуры pop ebx pop ebp pop edi pop esi ret end start
Запись от K_ILYA_V размещена 21.01.2021 в 23:23
Обновил(-а) K_ILYA_V 21.01.2021 в 23:31 -
О, да! А jnz @b и jnz next - это, как я понимаю - комментарий? И какое отношение все это имеет к теме заметки (что быстрее - сортировать или вставлять в отсортированный массив)? А если нужно было бы сортировать записи по сложным ключам, вам бы тоже хватило 13 команд?
Запись от Catstail размещена 09.04.2021 в 15:15 -
Запись от K_ILYA_V размещена 09.04.2021 в 17:01
Обновил(-а) K_ILYA_V 09.04.2021 в 17:13 -
Запись от Curry размещена 09.04.2021 в 17:16
Обновил(-а) Curry 09.04.2021 в 17:20 -
Запись от Curry размещена 09.04.2021 в 17:18 -
это придирки. очевидно что с ростом количества обрабатываемых чисел накладные расходы на вход и выход будут стремиться к нулю в процентном отношении и значение будет иметь количество и скорость выполнения именно этих 13 команд.
Запись от K_ILYA_V размещена 09.04.2021 в 17:20 -
Запись от K_ILYA_V размещена 09.04.2021 в 17:26
Обновил(-а) K_ILYA_V 09.04.2021 в 17:28