Алгоритмика: Бинарный поиск и рекурсия
Glossary overview

Алгоритмика: Бинарный поиск и рекурсия

Допустим, что вы вводите свои данные при входе на Facebook. При этом Facebook необходимо проверить, есть ли у вас учетная запись на сайте. Для этого ваше имя пользователя нужно найти
в базе данных. Допустим, вы выбрали себе имя пользователя “karlrnageddon”.
Facebook может начать искать с буквы А и проверять все подряд, но разумнее будет начать с середины.

Перед нами типичная задача поиска. И во всех этих случаях для решения задачи можно применить один алгоритм: бинарный поиск.
Бинарный поиск – это алгоритм; на входе он получает отсортированный список элементов (позднее я объясню, почему он должен быть отсортирован). Если элемент, который вы ищете, присутствует в списке, то бинарный поиск возвращает ту позицию, в которой он был найден. В противном случае бинарный поиск возвращает null

Пример

Рассмотрим пример того, как работает бинарный поиск. Сыграем в простую игру: я загадал число от 1 до 100.

Предположим, вы начинаете перебирать все варианты подряд: 1, 2, 3, 4…

При каждой догадке исключается только одно число. Если я загадал число 99, то , чтобы добраться до него, потребуется 99 попыток!

Более эффективный поиск

Существует другой, более эффективный способ. Начнем с 50.

Слишком мало … но вы только что исключили половину чисел! Теперь вы знаете, что все числа 1-50 меньше загаданного. Следующая попытка: 75.

С бинарным поиском вы каждый раз загадываете число в середине диапазона и исключаете половину оставшихся чисел.

В общем случае для списка из n элементов бинарный поиск выполняется за log2n шагов, тогда как простой поиск будет выполнен за n шагов. Бинарный поиск это “сколько раз можно делить диапазон пополам, пока не останется 1 элемент”. Но математика та же, потому что это про степени двойки.

Для n = 100:

  • log2(100) это число, x, такое что 2^x = 100
  • примерно: log2(100) ≈ 6.64

А по шагам (целыми сравнениями):

  • 2^6 = 64 (мало)
  • 2^7 = 128 (уже достаточно)

Значит нужно максимум 7 шагов (сравнений), чтобы найти элемент (или понять, что его нет) в списке из 100 элементов, если он отсортирован.

Для сравнения: линейный поиск в худшем случае до 100 шагов.

Пример

Функция binary_search получает отсортированный массив и значение. Если
значение присутствует в массиве, то функция возвращает его позицию. При
этом мы должны следить за тем, в какой части массива проводится поиск.
Вначале это весь массив:

low = 0
high = len(list) - 1 

Каждый раз алгоритм проверяет средний элемент:

mid = (low + high)/2 // Если значение {low+high) нечетно, то Python автоматически округляет значение mid в меньшую сторону 

guess = list[mid] 

Если названное число было слишком мало, то переменная low обновляется
соответственно – мы его увеличиваем. Теперь 1-й указатель стоит на начале второй половины массива:

if guess < item :
low = mid + 1 

А если догадка была слишком велика, то обновляется переменная high. Полный
КОД ВЫГЛЯДИТ так:

def binary_search(list, item):
    # В переменных low и high хранятся границы
    # той части списка, в которой выполняется поиск
    low = 0
    high = len(list) - 1
    
    # Пока эта часть не сократится до одного элемента...
    while low <= high:
        # ...проверяем средний элемент
        mid = (low + high) // 2
        guess = list[mid]
        
        # Значение найдено
        if guess == item:
            return mid
        
        # Много - ищем в левой половине
        if guess > item:
            high = mid - 1
        # Мало - ищем в правой половине
        else:
            low = mid + 1
    
    # Значение не существует
    return None


# А теперь протестируем функцию!
my_list = [1, 3, 5, 7, 9]

# Вспомните: нумерация элементов начинается с 0.
# Второй ячейке соответствует индекс 1
print(binary_search(my_list, 3))   # => 1

# "None" в Python означает "ничто".
# Это признак того, что элемент не найден
print(binary_search(my_list, -1))  # => None

Задача: найти Имя в массиве

<?php

$names = [
    "Александр",
    "Анастасия",
    "Андрей",
    "Анна",
    "Артём",
    "Виктория",
    "Владимир",
    "Дарья",
    "Дмитрий",
    "Евгений",
    "Екатерина",
    "Елена",
    "Иван",
    "Ирина",
    "Кирилл",
    "Ксения",
    "Максим",
    "Мария",
    "Михаил",
    "Наталья",
    "Никита",
    "Ольга",
    "Павел",
    "Полина",
    "Сергей",
    "Светлана",
    "Татьяна",
    "Юлия"
];

function findName($names, $name) {
	/* указатели на 1-й и последний элемент в массиве*/
	$low = 0;
	$high = count($names) - 1; // 27
	
	while ($low <= $high) {
		/*берем имя по центру*/
		$mid = floor(($low + $high) / 2); // 13
		
		
		if ($names[$mid] == $name) {
			return $mid;
		} 
		
		if ($names[$mid] < $name) {
			/*ищем во второй половине массива*/
			$low = $mid + 1; // 14
		}
		
		if ($names[$mid] > $name) {
			/*ищем в первой половине массива*/
			$high = $mid - 1; // 12
		}
		
	} 
	
	return null;
}

$result = findName($names, 'Дарья');
var_dump($result); // 7

Вариант с рекурсией

Рекурсия в PHP — это прием программирования, при котором функция вызывает саму себя.

Любая рекурсивная функция должна иметь два компонента:

  1. Условие выхода (базовый случай): точка, где вызовы прекращаются. Без неё возникнет бесконечный цикл и ошибка переполнения стека.
  2. Рекурсивный шаг: вызов функцией самой себя с измененными аргументами.
<?php

$names = [
    "Александр",
    "Анастасия",
    "Андрей",
    "Анна",
    "Артём",
    "Виктория",
    "Владимир",
    "Дарья",
    "Дмитрий",
    "Евгений",
    "Екатерина",
    "Елена",
    "Иван",
    "Ирина",
    "Кирилл",
    "Ксения",
    "Максим",
    "Мария",
    "Михаил",
    "Наталья",
    "Никита",
    "Ольга",
    "Павел",
    "Полина",
    "Сергей",
    "Светлана",
    "Татьяна",
    "Юлия"
];

function recursiveFindName($names, $name, $low, $high) {
	/*выходим с функции*/
	if ($low > $high) return null;
	
	var_dump('Low: ' . $low);
	var_dump('High: ' . $high);
	
	/*берем имя по центру*/
	$mid = floor(($low + $high) / 2); // 13
	
	if ($names[$mid] == $name) {
		/*Нашли*/
		return $mid;
	} 
	
	if ($names[$mid] < $name) {
		/*ищем во второй половине массива*/
		$low = $mid + 1; // 14
		
		return recursiveFindName($names, $name, $low, $high);
	}
	
	if ($names[$mid] > $name) {
		/*ищем в первой половине массива*/
		$high = $mid - 1; // 12
		
		return recursiveFindName($names, $name, $low, $high);
	}
		
}

$result = recursiveFindName($names, 'Ольга', 0, count($names) - 1);
var_dump($result); // 21

Вот для наглядности логируем как менялись индексы указателей.

string(6) "Low: 0"
string(8) "High: 27"
string(7) "Low: 14"
string(8) "High: 27"
string(7) "Low: 21"
string(8) "High: 27"
string(7) "Low: 21"
string(8) "High: 23"
string(7) "Low: 21"
string(8) "High: 21"
float(21)

Почему мы возвращаем функцию, а не вызываем ее?

return recursiveFindName($names, $name, $low, $high);

Наша функция возвращает результат, только если мы нашли то, что искали либо null.

function recursiveFindName($names, $name, $low, $high) {
	/*выходим с функции*/
	if ($low > $high) return;
        ....
        if ($names[$mid] == $name) {
		/*Нашли*/
		return $mid;
	} 

А если на текущей итерации не нашли, то return говорит: “верни то, что вернёт следующий вызов”. Это как передать эстафетную палочку обратно по цепочке 🏃‍♂️🏃‍♂️🏃‍♂️

Текущая итерация говорит: “мой результат = результат следующего вызова”. Она как бы делегирует ответ дальше по цепочке.

По сути говорим: “на текущей итерации не получилось вернуть результат, давай поменяем аргументы, снова вызовем функцию и может она тогда вернет нужный результат“.

И только последняя итерация (которая нашла элемент или дошла до $low > $high) возвращает конкретное значение — индекс или null. А все предыдущие просто передают это значение наверх.

вызов 1: return recursiveFindName(...)  // ждёт результат от вызова 2
    вызов 2: return recursiveFindName(...)  // ждёт результат от вызова 3
        вызов 3: return 21  // нашли!
    вызов 2: получил 21, возвращает 21
вызов 1: получил 21, возвращает 21

Итог:

Каждый вызов рекурсии либо:

  • возвращает готовый ответ (нашёл / не нашёл)
  • либо делегирует задачу следующему вызову с уточнёнными параметрами