Skip to main content

Хэши

Для дальнейшего понимания работы git, нам нужно рассмотреть довольно важное понятие хэша. Хэш - это особая свёртка большой структуры в маленькую.

Представим, что у нас есть большой массив строк, и мы хотим установить, содержит ли этот массив интересующую нас строку или нет. Да, мы можем просто пройти по всем строкам массива и сравнить их с нашей.

Если такая задача встаёт перед нами разово, то можно её решить и так. Но если нам нужно выполнять её регулярно или много раз, тогда уже производительность начинает играть важную роль.

Чтобы ускорить поиск строки в массиве, мы можем каждой из них сопоставить более простые величины, например, числа, и сравнивать их, а потом уже при совпадении этих простых величин проверять сами строки. Самый простой пример такой величины - это длина строки. Если строки имеют разные длины, то они очевидно не могут быть равны.

Полный перебор будет выглядеть так:

public class Example1_1 {
public static void main(String[] args) {
// массив строк
String[] arr = new String[]{
"geefewf323ggr343f34f43f4creceferegregf34t",
"geefewf323ggr343fwe3sdqqxreceferegregf34t",
"sadxsewwewefkjrk34rk3n99km23kr23re23ndxnk",
"dsc343c432rbdfbdfbvv4ev45c4wef23wr32r23r2",
"sdvcewwexewfdsdvgfberwevcdsvcsdfsdfswrefe",
"trthtrhtrhtrgsdxczsdfrgf4e5g34gftregfsdsg"
};
// искомая строка
String target = "sadxsewwewefkjrk34rk3n99km23kr23re23ndxnk";
// запоминаем время старта программы в наносекундах
long startTime = System.nanoTime();
// находим длину искомой строки
int targetLength = target.length();

// ищем первую строку
for (int i = 0; i < arr.length; i++) {
// проверяем совпадает ли рассматриваемая строка с искомой
// изначально будем считать, что да
boolean areEqual = true;
// сравниваем строки поэлементно
for (int j = 0; j < Math.min(targetLength, arr[i].length()); j++) {
if (arr[i].charAt(j) != target.charAt(j)) {
// запоминаем, что строки не равны
areEqual = false;
// завершаем цикл сравнения
break;
}
}
// если строки совпали
if (areEqual) {
// выводим сообщение об этом
System.out.println("Элемент массива с индексом " + i + " совпадает с искомой строкой");
}
}
// запоминаем время окончания программы в наносекундах
long endTime = System.nanoTime();
// выводим результат в миллисекундах
System.out.printf("Затраченное время: %.2f мс.", (double) (endTime - startTime) / 1e6);
}
}

Получим:

Элемент массива с индексом 2 совпадает с искомой строкой

Время, затраченное на выполнение :

  • Java: 18,98 мс.
  • C++: 2.41 мс.
  • Python: 0.09 мс.

Времени, которое выдаёт python доверять не стоит, там сложные цепоки оптимизации, поэтому даже просто время исполнения одного и того же кода "плавает".

Теперь учтём, что строки разной длины не могут совпасть

public class Example1_2 {
public static void main(String[] args) {
// массив строк
String[] arr = new String[]{
"geefewf323ggr343f34f43f4creceferegregf34t",
"geefewf323ggr343fwe3sdqqxreceferegregf34t",
"sadxsewwewefkjrk34rk3n99km23kr23re23ndxnk",
"dsc343c432rbdfbdfbvv4ev45c4wef23wr32r23r2",
"sdvcewwexewfdsdvgfberwevcdsvcsdfsdfswrefe",
"trthtrhtrhtrgsdxczsdfrgf4e5g34gftregfsdsg"
};
// искомая строка
String target = "sadxsewwewefkjrk34rk3n99km23kr23re23ndxnk";

// запоминаем время старта программы в наносекундах
long startTime = System.nanoTime();
// создаём второй массив длин строк
int[] lengths = new int[arr.length];
for (int i = 0; i < arr.length; i++) {
lengths[i] = arr[i].length();
}
// находим длину искомой строки
int targetLength = target.length();

// ищем первую строку
for (int i = 0; i < arr.length; i++) {
// если длины строк не совпадают, переходим к следующей
if (lengths[i] != targetLength)
// переходим к следующей итерации цикла перебора строк в масиве
continue;
// проверяем совпадает ли рассматриваемая строка с искомой
// изначально будем считать, что да
boolean areEqual = true;
// сравниваем строки поэлементно
for (int j = 0; j < lengths[i]; j++) {
if (arr[i].charAt(j) != target.charAt(j)) {
// запоминаем, что строки не равны
areEqual = false;
// завершаем цикл сравнения
break;
}
}
// если строки совпали
if (areEqual) {
// выводим сообщение об этом
System.out.println("Элемент массива с индексом " + i + " совпадает с искомой строкой");
}
}
// запоминаем время окончания программы в наносекундах
long endTime = System.nanoTime();
// выводим результат в миллисекундах
System.out.printf("Затраченное время: %.2f мс.", (double) (endTime - startTime) / 1e6);
}
}

Получим:

Элемент массива с индексом 2 совпадает с искомой строкой

Время, затраченное на выполнение :

  • Java: 14,83 мс.
  • C++: 1.42 мс
  • Python: 0.047 мс.

Длина, конечно, - полезная функция свёртки, но хорошо бы было построить такую функцию, которая бы тоже в результате давала целое число, но при этом она бы возвращала сильно разные значения для разных строк. Длина строки слишком слишком грубая. Поэтому строки, имеющие одинаковую длину могут быть как равны, так и не равны.

Нам нужно построить такую функцию, которая бы возвращала целое число и при этом, если эти числа равны, то скорее всего и исходные структуры тоже равны. Такие функции называются хэшами(hash-функциями).

Хэш-функций придумали довольно много: их используют в довольно разных сферах. Например, они очень востребованы в криптографии. Там мы не ищем строку по хэш-функции в массиве, а проверяем, совпадает ли хэш пароля, хранимый на сервере с хэшом строки, введённой пользователем.

К таким функциям предъявляется дополнительное требование необратимости. Т.е. имея хэш, мы никак не сможем восстановить исходную строку. У криптографичческих хэш-функций время от времени находят уязвимости, которые позволяют немного облегчить взлом, но всё равно он сводится к перебору паролей, вычислению их хэшей и сравнению с известным. На этом же принципе перебора выполняется, например, майнинг биткоина.

В этой статье рассказывается, как рассчитать хэш для биткоина с помощью бумажки и ручки. Конечно, там показан пример для начальных блоков биткоина, их хэш считать очень просто. А вот каждый последующий вычисляется всё сложнее, потому что хэш считается по всем блокам, которые уже посчитаны, плюс новая неизвестная последовательность кодов.

Иными словами, вычислительная цена каждого следующего биткоина всё выше и выше. Для перебора хэшей криптовалют майнеры(люди, подбирающие хэши) объединяются в пулы. Каждый в пуле перебирает свою часть хэшей, а премию за успешно подобранный хэш делят пропорционально вычислительному вкладу.

Git тоже считает хэши. Ему они нужны для быстрого поиска файлов и проверки наличия изменений в них. Для этой задачи разработчики выбрали SHA-1.

Заменим теперь в нашей программе длины строк на хэши. В случае SHA-1 хэш имеет размер 160160 бит. Его отображают при помощи 4040 шестнадцатиричных символов. Такое число не влезает в классические переменные, поэтому использовать его в нашем примере уже не будем. Для программистских задач он абсолютно не подходит, а вот для файлов - вполне. Ниже приведён пример формирования SHA-1 хэша для последовательности байт, соответствующей строке Hello, World!.

В C++ нет готовой функции sha-1, поэтому для её работы, нужно установить библиотеку OpenSSL. Чтобы установить openssl на Windows, придётся очень помучиться, на Linux это проще.

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

public class Example1_3 {

// преобразовать массив байтов в строку
public static String byteArrayToHexString(byte[] b) {
String result = "";
// каждый байт переводим в красивое представление
for (int i = 0; i < b.length; i++) {
result += Integer.toHexString((b[i] & 0xff) + 0x100).substring(1);
}
return result;
}


public static void main(String[] args) {
// исходные данные Hello, World!
byte[] source = new byte[]{'H', 'e', 'l','l','o',',',' ','W','o','r','l','d','!'};
// переменная для формирования хэша
MessageDigest md = null;
try {
// получаем объект для формирования хэша
md = MessageDigest.getInstance("SHA-1");
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
}
// получаем хэш, как массив байтов
byte[] res = md.digest(source);
// выводим строковое представление байт
System.out.println(byteArrayToHexString(res));
}
}

Получим:

0a0a9f2a6772942557ab5355d76af442f8f65e01

За счёт хэшей git может очень быстро работать с довольно большими по объёму данными. Поэтому нет никакой необходимости что-либо удалять из его внутренней структуры. Грубо говоря, после того, как вы сделаете коммит, его будет очень сложно потерять, особенно, если вы регулярно синхронизируете свой локальный репозиторий с каким-нибудь удалённым, например, с GitHub'ом.

Подробнее о восстановлении данных git, которые кажутся утерянными можно прочитать здесь.