Преобразование Уолша-Адамара
На сайте hackerrank.com есть отличная задача.
По заданному массиву short[] A;
найти максимальное количество его подмассивов, xor
элементов которых будет одинаковым. Сам этот xor
тоже нужно найти.
Максимальная длина массива равна 105, так что квадратичный алгоритм не укладывается в лимит по времени исполнения. Я в своё время с этой задачей не справился и сдался, решив подсмотреть авторское решение. И в этот момент я понял почему не справился — автор предлагал решать задачу через дискретное преобразование Фурье.
Подробная формулировка задачи
Сперва стоит подробно описать задачу, чтобы быть уверенным, что все поняли её одинаково. Задан массив A
целых чисел, каждое из которых находится в диапазоне от 1 до 65535 (границы включены). Допустим, что это массив . Рассмотрим все его подмассивы:
2 подмассива имеют xor
равный 1, так же 2 других подмассива имеют xor
равный 3.
2 — максимальное количество подмассивов с одинаковым xor
-ом. Сам же xor
выбираем равным 1, как минимальное значение из всех альтернатив (1 или 3). Итого решение задачи — пара .
Нужно лишь научиться делать то же самое для массивов размером вплоть до 100000. Длину массива будем обозначать как N
.
Наивное решение
Наивное решение предполагает двойной цикл по вспомогательному массиву B
, строящийся следующим образом:
B[0] = 0
B[i] = B[i-1] ^ A[i-1]
Массив B
имеет длину N+1
. При таком построении каждый B[i]
(при i > 0
) будет равен xor
-у всех элементов A[k]
для 0 <= k < i
. Данное свойство очень удобно, потому что в этом случае xor
подмассива [A[i], ..., A[j]]
будет равен B[j+1] ^ B[i]
, т.е. вычисляем за константное время. Сам же массив строится за линейное время, тоже быстро.
Остаётся лишь посчитать xor
всех подмассивов. Псевдокод будет выглядеть следующим образом (65536 — это 216):
int[] frequencies = new int[65536];
for (int i = 0; i < N; i++) {
for (int j = i; j < N; j++) {
++frequencies[B[j + 1] ^ B[i]];
}
}
int maxFrequency = max(frequencies);
int xor = indexOf(frequencies, maxFrequency);
Тройного цикла мы избежали, а вот двойной остался, и это плохо. В нём будет итераций, что в худшем случае равно примерно 5×109.
Учитывая, с какой частотой обычно работают процессоры, можно заключить, что этот цикл будет исполняться несколько секунд. На практике решение не укладывается во временные рамки, настроенные на сайте (4 секунды для Java, например).
Какое решение предлагает автор
Официальное решение из секции Editorial
Неправильное.
То есть как неправильное, последующий код в Editorial приведён верный, а вот текстовое описание со скриншота — полнейшая бессмыслица, с кодом не совпадающая.
Хотя и к коду у меня тоже есть вопросы, но эту часть опустим, скажу просто, что он там странный.
Я буду своими словами объяснять то, что на самом деле имелось в виду, а не то, что там написано. И сделаю это не за 3 строки текста. Идея финального алгоритма такая:
long[] C = new long[65536];
for (int i = 0; i <= N; i++) {
++C[B[i]];
}
convolute(C);
C[0] -= N + 1;
long maxFrequency = max(C) / 2;
long xor = indexOf(C, maxFrequency * 2);
Решение состоит из 2-х частей: свёртки вспомогательного массива C
(а не массива B
, как было заявлено) и выражению C[0] -= N + 1
. К моменту вызова max(C)
массив C
должен содержать в точности те же данные, что и массив frequencies
в наивном решении, но умноженные на 2.
Приблизимся к пониманию этого кода, подумав, как по изначальному массиву C
вообще можно получить ответ. Это и будет являться ключом к решению.
В наивном решении мы делали ++frequencies[k]
для всех k == B[i] ^ B[j]
,
где 0 <= i < j <= N
(здесь я произвёл замену переменной j + 1 -> j
, чтобы не таскать за собой +1
, так меньше путаницы).
С помощью ++
в итоге мы считали количество пар (i, j)
, для которых B[i] ^ B[j]
одинаковы и равны k
. Иными словами, для всех i < j
и k == B[i] ^ B[j]
мы должны просуммировать единицы, чтобы получить frequencies[k]
.
Рассмотрим тот же пример, что был показан при подробной формулировке задачи.A = [1, 2, 3]
Далее в индексах при
B = [0, 1, 3, 0] ([0, 0^1, 0^1^2, 0^1^2^3])
C = [2, 1, 0, 2, ...]1
написаны соответствующие им пары (i, j)
:
Вроде бы сходится со значениями, которые мы видели ранее.
Начнём преобразовывать эту формулу до тех пор, пока не получится что-нибудь путное. В первую очередь стоит избавиться от i < j
: такая сложная индексация в сумме сильно затрудняет формулу. Рассмотрим альтернативное значение:
В нём порядок i
и j
игнорируется, а также допускается их полное равенство. Это можно показать явно:
А значит, нам достаточно вычислять более простой .
Что, если некоторые B[i]
совпадают? Кажется, что в общем случае это справедливое допущение. Если бы мы заранее знали, что B[i_1] == B[i_2] == ... == B[i_n]
, то подсчёт количества всех B[i_k] ^ B[j]
был бы тривиален, таких пар было бы ровно n
штук.
Процедуру вычисления можно видоизменить, если сделать её следующим образом:
для всех i
таких, что все B[i]
попарно различны и k == B[i] ^ B[j]
, мы должны просуммировать count(B[i])
, чтобы получить. Похоже на то, что было раньше, но суммируются не единицы, да и i
учитываются не все.
Фраза bi is unique не совсем корректна, но, надеюсь, она даёт интуитивное понимание происходящего. Что, если провести такую же группировку и по индексам j
? Получим следующее:
для всех i
таких, что все B[i]
попарно различны, всех j
таких, что все B[j]
попарно различны и k == B[i] ^ B[j]
, мы должны просуммировать count(B[i]) * count(B[j])
, чтобы получить .
Если посмотреть внимательно на код решения, то будет видно, что массив C
в нём как раз вычисляет count
, т.е. C[B[i]] == count(B[i])
. Далее, i
и j
можно будет вообще выбросить из обсуждения, сделав небольшую хитрость.
Если какое-то значение x
не встречается в массиве B
, то C[x] == 0
. Если же какое-то значение x
встречается в B
, то оно встречается в точности C[x]
раз. Это хорошее свойство. Учитывая его, предлагаю посмотреть, что будет при вычислении подобной суммы:
которую можно переписать немного иначе, избавившись от буквы q
:
Если p
или q
отсутствует в массиве B
, то соответствующее значение count
будет равно нулю и слагаемое будет игнорироваться. В противном случае p
и q
будут соответствовать уникальным значениям в B
. Другими словами, это буквально та же самая сумма.
Мы можем написать следующий код, который должен быть эквивалентен строке convolute(C)
:
long[] tmp = new long[65536];
for (int p = 0; p < 65536; p++) {
for (int q = 0; q < 65536; q++) {
tmp[p ^ q] += C[p] * C[q];
}
}
C = tmp;
Именно такая сумма произведений называется свёрткой массива C
с самим собой.
Так же стало понятно, откуда взялись C[0] -= N + 1
и последующее деление на 2, из соотношения междуи.
Вычисляться свёртка будет с помощью быстрого преобразования Уолша-Адамара (FWHT), работающего за O(M*log(M))
(у нас M = 65536
) и ещё одного волшебного действия, о котором чуть позже.
Большая часть статьи как раз будет посвящена объяснениям того, что это за преобразование, что такое свёртка в более общем случае, как они связаны да и что такое дискретные преобразования Фурье в целом.
Дискретное преобразование Фурье
Любое дискретное преобразование Фурье (DFT) — вещь достаточно простая. На входе есть вектор длины, на выходе есть вектордлины, посередине находится умножение матрицы на вектор. Или вектора на матрицу, это не принципиально. Начну я с классического дискретного преобразования Фурье, а к преобразованию Уолша-Адамара (WHT) перейду попозже.
Для самых обычных векторовразмерностииспользуется матрица
где — корень-й степени из 1. Это в буквальном смысле полное определение дискретного преобразования Фурье. Во всяком случае оно эквивалентно всем остальным.
Есть ещё обратное преобразование, с помощью которого из результирующего вектораможно обратно получить исходный вектор, оно выражается обратной матрицей
в которой — это нормирующий коэффициент.
Тут дело в том, что определитель матрицыравен.
В общем виде элементы матрицы выглядят следующим образом:
Преобразование системы координат
Про дискретные преобразования Фурье часто пишут, что они раскладывают сигнал на гармоники, или типа того. Все эти интерпретации в рамках данной статьи меня не интересуют. Чисто алгебраически дискретное преобразование Фурье — это просто перевод вектора из одного базиса в другой (частный случай аффинного преобразования). Просто базис подбирается таким образом, что перевод в него начинает обладать некоторыми волшебными свойствами.
Сейчас будет небольшое отступление, которое объясняет как этот перевод работает на деле.
Для DFT базисом является система векторов :
— это просто коэффициенты в разложении векторапо векторам. Более того, то, что тут написано — это буквально формула обратного DFT в более развёрнутом виде. Теперь увидим, почему, посчитанные через прямое DFT, удовлетворяют данному равенству. Для этого, в первую очередь, стоит заметить, что
Верхняя черта над — это символ комплексного сопряжения, превращающий в . Это сопряжение можно переписать иначе:
Данное выражение уже подозрительно похоже на строку матрицы, разве что наумножить нужно. Да и вообще, матрица— унитарная, с поправкой на нормирующий коэффициент.
Если матрицу нормировать, то она станет воистину унитарной. Распишем прямое преобразование:
Здесь— это скалярное произведение двух комплексных векторов, равное .
Один из множителей в каждом слагаемом обязан быть заменённым на комплексно-сопряжённый, определение такое. Если собрать всё в кучу, то выйдет следующее:
А это уже просто разложение вектора по ортогональному базису, которое в общем виде выглядит так:
Проверить ортогональность несложно, нужно лишь показать, что, для этого достаточно знать формулу суммы геометрической прогрессии.
Теорема о свёртке
Свёртка — это операция, позволяющая из 2-х векторов получить 3-й.
Записывается она символом :
Как и в случае с самим DFT, у меня не стоит цели рассказать, зачем свёртка нужна. Меня просто интересуют её конкретные свойства. Одно из них — это то, как свёртка связана с DFT:
Дискретное преобразование Фурье превращает свёртку в покомпонентное умножение векторов, так же известное как произведение Адамара:
На практике это означает, что свёртку можно вычислять с помощью прямого DFT, умножения результатов и применению обратного DFT. И это быстрее прямого вычисления свёртки по формуле из определения, поскольку для DFT есть эффективный алгоритм, называемый быстрым преобразованием Фурье (FFT).
Предлагаю разобрать, почему это свойство работает, потом мы наконец-то перейдём к обещанному преобразованию Уолша-Адамара. В первую очередь перепишем формулу свёртки в немного другой форме:
Мне такой вариант записи всегда нравился больше, чем классический. Более симметрично, что ли. Подействуем на этот вектор дискретным преобразованием Фурье:
Здесь— это k-я строка матрицы(или столбец — это неважно, матрица симметричная).
Каждый компонент результирующего вектора выглядит следующим образом:
В выражении выше я произвёл замену индексов суммирования. Надеюсь, больших вопросов она не вызывает.
Как будет выглядеть произведение Адамара, стоящее справа в теореме о свёртке?
Очень похоже. Вот если бы равнялось…
Хотя погодите, оно и равняется, ведь, и выражение выше следует напрямую из свойств показательной функции.
Вот оно, то волшебное свойство матрицы, которое позволяет теореме о свёртке работать! А это значит, что если взять другую матрицу с тем же свойством, то теорема о свёртке выполнится и для неё.
Отсюда есть несколько интересных выводов:
Симметричные формулы для переставленных индексов тоже верны. Матрица для DFT полностью определяется элементом: , иначе теорема о свёртке просто не выполнится.
Преобразование Уолша-Адамара
WHT является частным случаем преобразования Фурье на группах, которое, в свою очередь, является обобщением дискретного преобразования Фурье.
Преобразование Уолша-Адамара тоже является переводом вектора размерностииз одного базиса в другой и тоже выражается с помощью умножения матрицы на вектор, но при дополнительных ограничениях на:
Вместов формулах принято использовать сам показатель степени.
Матрица преобразования, называемая матрицей Адамара, вводится рекуррентно, начиная со случая(0 я проигнорирую, слишком скучно). При этом, вначале мы получим в точности матрицу обычного DFT. Матрицы бОльших размерностей строятся из матриц меньших размерностей.
Например
Начнём со сходств с дискретным преобразованием Фурье. Матрица— ортогональная, т.е. тоже описывает перевод в ортогональный базис, это легко проверить самостоятельно. Выражение для обратного преобразования идентично выражению для DFT:
Знак * можно вообще убрать из формулы выше, поскольку матрица преобразования симметричная и содержит только вещественные числа.
Теперь различия. Для WHT не выполняется теорема о свёртке, поскольку . Поскольку WHT — это вариант обобщённого DFT, то и его теорема о свёртке должна быть вариантом обобщения теоремы о свёртке для DFT. Попробуем прийти к этому обобщению.
Если смотреть на матрицу «рекурсивно», то видно, что элементы меняют знак, если они находятся в правой нижней подматрице во время «рекурсивного спуска». Если вычислить, сколько раз меняется знак, то можно будет легко получить значение элемента массива как.
Для матрицы 2 на 2 это просто сделать, . Что же касается матриц больших размеров
Все индексы в нижней половине матрицы больше либо равны 2m-1, аналогично все индексы в правой половине матрицы больше либо равны 2m-1. Такие индексы имеют единичный бит в позиции, а значит при рекурсивном спуске нам нужно будет домножать значение элемента на (по аналогии с матрицей 2 на 2).
Если перемножить эти выражения для всех битов чисели, то получим следующее:
Тут главное не запутаться. В программировании &
— это конъюнкция, а ^
— это исключающее или. В математике — это конъюнкция, а — это исключающее или. Под записью я имею ввиду побитовую конъюнкцию. А выражение фактически равно скалярному произведениюи, если представлять их себе как битовые вектора.
Мы снова получили показательную функцию в выражении для элементов матрицы, а значит вполне можем рассчитывать на свойства, похожие на свойства матрицы.
Свёртка для WHT
Конъюнкция битовых векторов — это их побитовое произведение. А исключающее или битовых векторов — это их побитовое сложение по модулю 2. Эти 2 операции ведут себя очень схоже с обычными умножением и сложением. В частности, выполнено свойство дистрибутивности:
Так же важно обратить внимание, что
Учитывая это, мы можем снова воспользоваться свойствами показательных функций и заключить следующее:
Как помним, для DFT у нас была похожая формула, только сложение там было другое:
Именно это свойство использовалось для доказательства теоремы о свёртке, где формулы для свёрткибыли следующими:
По аналогии мы можем получить теорему о свёртке для преобразование Уолша-Адамара:
Теорема о свёртке выполняется, просто сумма индексов в ней не , а . И это в точности та же самая формула, которую должен вычислить метод convolute
.
Используя другие операции вместоили сложения по модулю можно получать другие варианты дискретных преобразований Фурье. Их, понятное дело, может быть очень много.
Быстрое преобразование Уолша-Адамара
Чтобы вычислить свёртку двух векторов, пользуясь теоремой о свёртке, нужно произвести WHT над аргументами, вычислить произведение Адамара и выполнить обратное преобразование. В нашем частном случае это приведёт к подобному коду:
static final int M = 65536;
static void convolute(long[] C) {
wht(C);
for (int i = 0; i < M; i++) {
C[i] *= C[i];
}
wht(C);
for (int i = 0; i < M; i++) {
C[i] /= M;
}
}
Помним, что . Отсюда и деление в конце.
Всё, что нам осталось — это реализовать метод wht
:
static void wht(long[] C) {
for (int h = 1; h < M; h *= 2) {
for (int i = 0; i < M; i += (h * 2)) {
for (int j = 0; j < h; j++) {
long x = C[i + j],
y = C[i + j + h];
C[i + j] = x + y;
C[i + j + h] = x - y;
}
}
}
}
Данный алгоритм вовсю эксплуатирует рекурсивную структуру матриц Адамара, буквально повторяя рекуррентное определение матриц при вычислении. Внешний цикл — логарифмический, а 2 внутренних цикла вместе делают один проход по массиву, просто не по порядку. Подробный разбор этой реализации выходит за рамки статьи.
Заключение
Хорошая задача. Сложная. Знаете, что в ней самое интересное? То, что я вас обманул. Не специально, конечно же.
Ближе к началу статьи я говорил, что двойной цикл по массиву B
выполнит примерно 5×109 итераций и это слишком много. Это правда. А сколько итераций выполнил бы двойной цикл по массиву C
, если бы мы вычисляли свёртку напрямую?
Примерно 2×109, если итерироваться для j >= i
, это почти в 2 с половиной раза меньше. Для Java ограничение по времени — 4 секунды. Может прокатит?
public static void main(String[] args) {
int N = readUnsignedInt();
int B[] = new int[N + 1];
for (int i = 0; i < N; i++) {
B[i + 1] = B[i] ^ readUnsignedInt();
}
int C[] = new int[M];
for (int i = 0; i <= N; i++) {
++C[B[i]];
}
int tmp[] = C;
C = new int[M];
for (int i = 0; i < M; i++) {
for (int j = i; j < M; j++) {
C[i ^ j] += tmp[i] * tmp[j];
}
}
C[0] = (C[0] - N - 1) / 2;
int max = Integer.MIN_VALUE, imax = 0;
for (int i = 0; i < M; i++) {
if (C[i] > max) {
max = C[i];
imax = i;
}
}
System.out.print(imax);
System.out.print(' ');
System.out.println(max);
}
Прокатило!
Получается, что задача решается и без преобразования Уолша-Адамара. Т.е. тот факт, что я изначально не смог решить эту задачу и подглядел ответ, никак не связан с тем, что я не знаком c WHT. Он связан с тем, что я поленился подольше посидеть над задачей, и признавать это сейчас немного стыдно.
А ведь автор задачи мог исключить возможность такого решения, если бы вместо 16-битных чисел использовал, к примеру, 17-битные. Вместе с тем фактом, что в Editorial написана какая-то ерунда, я заключаю, что автор задачи — на самом деле не автор. Почти наверняка он спёр её с другого сайта, может даже не разобравшись до конца в нужной математике и не подогнав условия под тайминги на новом сайте. Искать эту задачу в других источниках мне уже не хочется, так что эти мысли останутся просто гипотезой.
В любом случае я рад, что разобрался в предложенном решении, да и дискретное преобразование Фурье на деле оказалось намного более простым, чем я считал ранее.