Супер-эффективная архивация — сжимаем все что угодно до 32 байт
- среда, 3 апреля 2024 г. в 00:00:13
Сжатие данных используется в современном мире повсеместно, практические любое общение двух устройств происходит с сжатием и распаковкой данных для экономии объема передаваемых данных. Например, в HTTP
используются протоколы deflate
, gzip
(deflate с улучшениями). Однако, при некоторых условиях можно достичь куда большего сжатия данных, попробуем разработать такой алгоритм.
Есть теорема, которая описывает сжатие без потерь:
Для любого N > 0 нет алгоритма сжатия без потерь, который:
Любой файл длиной не более N байт или оставляет той же длины, или уменьшает.
Уменьшает некоторый файл длиной не более N хотя бы на один байт.
Докажем полную несостоятельность этой теоремы.
А что если использовать хэширование в качестве алгоритма сжатия, например SHA-256?
Во время сжатия будем брать SHA-256 хэш от входного файла и сохранять его в выходной файл вместе с заголовком PK
(прямо как в ZIP) и размером исходного файла (Int32, 4 байта).
static void Encrypt(string inputPath)
{
var outputPath = inputPath + ".gigazip";
var inputFile = File.ReadAllBytes(inputPath);
using var file = File.Open(outputPath, FileMode.Create);
file.Write([(byte)'P', (byte)'K']);
//Получаем длину файла в виде байтов (little-endian по умолчанию)
var length = BitConverter.GetBytes(inputFile.Length);
file.Write(length);
file.Write(SHA256.HashData(inputFile));
}
Как видно по примеру кода, алгоритм сжатия очень простой и не требует подключения никаких дополнительных библиотек. Архив с сжатыми данными (с учетом метаданных в начале файла) всегда будет весить ровно 38 байт!
Распаковка будет происходить с помощью алгоритма, очень похожего на Proof of work, использующийся в реализации различных криптовалют (Bitcoin, Ethereum).
Считываем файл архива, разбираем метаданные
Создаем массив длиной N, равной длине исходного файла, (далее выходной файл)
Методом перебора выбираем следующий вариант выходного файла
Сжимаем файл (берем его SHA-256 хэш), сравниваем его с данными архива
Если сжатый файл не совпадает с данными архива, переходим на шаг 3
Сохраняем текущий вариант файла как распакованный файл
static void Decrypt(string inputPath)
{
var bytes = File.ReadAllBytes(inputPath);
var length = BitConverter.ToInt32(bytes.Skip(2).Take(4).ToArray()); // 4 байта длины выходного файла
var sha256 = bytes.Skip(6).Take(32).ToArray(); // 32 байта сжатых данных
const int parallelismDegree = 5; // Будем считать параллельно в 5 потоках
var files = Enumerable.Range(0, parallelismDegree).Select(x =>
{
var array = new byte[length];
array[^1] = (byte)(x * (0xFF / parallelismDegree)); // указываем диапазон перебора для каждого из параллельных потоков
return array;
})
.ToArray();
Run(parallelismDegree, files, sha256, out var targetFile); // в targetFile будут лежать байты выходного файла
var outputFileName = inputPath[..^8]; // без .gigazip в конце
File.WriteAllBytes(outputFileName, targetFile);
}
private static void Run(int parallelismDegree, byte[][] files, byte[] sha256, out byte[] target)
{
var targetFile = 0;
Parallel.For(0, parallelismDegree, (index, state) =>
{
while (true)
{
if (state.ShouldExitCurrentIteration)
{
break; // останавливаемся, если распаковка завершена в другом потоке
}
var test = SHA256.HashData(files[index]);
if (test.FastCompareArrays(sha256))
{
targetFile = index;
break;
}
files[index].Increment(); // Выбираем следующий вараинт выходного файла
}
state.Break(); // Останавливаем вычисления
});
target = files[targetFile];
}
internal static class ByteArrayHelpers
{
[MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
public static unsafe void Increment(this byte[] array)
{
unchecked // <- отключаем проверку на переполнение типа, 255 + 1 = 0
{
var add = true;
// получаем указатель на массив, работа с ним быстрее (не происходит проверка рантаймом выхода за пределы массива)
fixed (byte* head = array)
{
for (var i = 0; i < array.Length - 1; i++)
{
head[i] += *((byte*)(&add)); // reinterpret_cast
// если 0, значит переполнили байт, прибавляем 1 к следующему
add = head[i] == 0;
}
}
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
public static unsafe bool FastCompareArrays(this byte[] one, byte[] two)
{
if (one.Length != two.Length)
{
return false;
}
fixed (byte* first = one)
{
fixed (byte* second = two)
{
for (var i = 0; i < one.Length; i++)
{
if (first[i] != second[i])
{
return false;
}
}
}
}
return true;
}
}
Эффективность данного алгоритма тем больше, чем больше размер исходного файла.
Сжатие:
O(N)
- временная сложность
O(1)
- пространственная сложность
Распаковка:
O(N^N^N^N...)
- временная сложность
O(N)
- пространственная сложность
Основной недостаток алгоритма - достаточно медленный процесс распаковки. Например, фильм Shrek 2 (DreamWorks Animation, все права защищены) в формате UHD весом 60ГБ будет распаковываться ориентировочно лет.
Однако, проблема неоднозначности распаковки (когда одному архиву соответствует несколько выходных файлов) на деле проблемой не является, что будет рассмотрено в следующем разделе.
Крайне маленький размер архива
Неоднозначность функции хэширования - возможность упаковать в 32 байта несколько входных файлов, к примеру:
Shrek 2, Shrek 1 (DreamWorks Animation, все права защищены)
Саундтрек к этим фильмам
Несколько видеоигр по мотивам этих фильмов
Также, при некоторой оптимизации алгоритма (например, использовании вычислений на видеокарте с помощью CUDA) можно уменьшить ожидаемое время распаковки с до лет (что уже немалый прогресс).
В рамках статьи был предложен инновационный алгоритм сжатия данных, который, надеюсь, в будущем будет использоваться в работе во большинстве крупных IT компаний.