Наткнулась на leetcode на задачку с нахождением квадратного корня из неотрицального числа.

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

Итак, условие задачи здесь: https://leetcode.com/problems/sqrtx/description/

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

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

Акцентирую внимание еще раз: массив должен быть отсортирован по возрастанию.

Если массив не отсортирован, то сортировка потребует минимум O(log n * n) временной сложности, что нужно учитывать.

Поэтому, если массив небольшой и неупорядоченный, то, скорее всего, лучше будет линейный поиск со сложностью O(n).

Итак, теперь вернемся к нашей задачке. Нужно найти квадратный корень из неотрицательного числа, где само число может быть любым от 0 до 231 - 1. Использовать встроенные функции при этом нельзя, так что return (int) Math.sqrt(x) не подойдет (хотя leetcode ее примет). Если корень из числа извлекается с остатком, например, корень из 8 это 2.82842…, то нужно округлить в меньшую сторону до целого, т.е. в данном случае до 2.

Начнем, по порядку, ограничив краевые случаи. Так, если х = 0, то можно сразу вернуть 0.

if (x == 0) {

    return 0;

}

Дальше зададим границы, левую и правую. Левая граница в данном случае - это 1, а правая само число Х. Также нам понадобиться переменная, в которую мы запишем результат:

int left = 1;

int right = x;

int result = 0;

Мы будем выполнять поиск нужного нам элемента, пока левая граница (left) будет меньше или равна правой (right), если левая граница станет больше, то значит результат уже был найден и нужно его возвращать.

while (left <= right) {
  //тут будет алгоритм
}

Внутри самого цикла while мы будем двигать границы, согласна правилу выше. Сначала найдем середину: 

while (left <= right) {
  int mid = left + (right - left) / 2;
  ...
}    

Если значение серединного элемента больше, чем заданное число деленное на серединный элемент, то надо двигать правую границу, иначе левую, и записывать промежуточный результат:

while (left <= right) {
  int mid = left + (right - left) / 2;
  if (mid > x / mid) {
    right = mid - 1;
  } else {
    left = mid + 1;
    result = mid;
  }
}

Допустим, заданное число - это 18. Тогда left = 1, right = 18,  заходим в цикл, так как 1 < 18 (left <=right), находим mid = 1 + (18 - 1) / 2 = 9. Проверяем условие: 9 > 18 / 9, поэтому двигаем правую границу влево: right = 9 - 1 = 8.

Снова проверяем условие 1 < 8 (left <=right), входим в цикл, находим середину: mid = 1 + (8 - 1) / 2 = 4. Рассчитываем x / mid - это 18 / 4 ≈ 4, значит mid = x / mid ( 4 ≈ 18 / 4 ), значит left = 5, result = 4 (подвинули границы вправо)

Так как условие, left <= right все еще выполняется, мы снова зайдем в цикл (хотя на самом деле мы уже нашли нужный result для нашего конкретного примера), снова рассчитаем mid = 5 + (8 - 5) / 2 ≈ 6, вычислим x / mid = 18 / 6 = 3,  попадем в условие mid > x / mid,  так как 6 > 3, пересчитаем right = 6 - 1 = 5. Снова зайдем в цикл, и выйдем уже на следующем пересчете right, когда right будет равно 4, а left останется без изменений. После этого будет возвращен результат.

Фиксируем полный метод для решения задачи:

public int mySqrt(int x) {
  if (x == 0) {
    return 0;
  }
  int left = 1;
  int right = x;
  int result = 0;
  while (left <= right) {
    int mid = left + (right - left) / 2;
    if (mid > x / mid) {
       right = mid - 1;
    } else if {
       left = mid + 1;
       result = mid;
    }
  }
  return result;
}

Вот так можно использовать бинарный поиск для нахождения квадратного корня из числа. Если еще хотите попрактиковаться, то вот еще задачка уровня «Easy», где применяется тот же подход: 

https://leetcode.com/problems/search-insert-position/description/

Комментарии (33)


  1. GeXoGeN
    29.07.2024 12:08
    +3

    Левая граница в данном случае - это 1, а правая само число Х.

    Правой границей можно сразу делать Х/2. и ещё, наверное, умножение будет работать быстрее деления вот тут:

    (mid > x / mid)


    1. akozyrenko Автор
      29.07.2024 12:08

      Нельзя, потому что если х = 1, в ответе вы получите 0, что неверно.


      1. GeXoGeN
        29.07.2024 12:08
        +2

        ну для х = 0 частный случай же Вы определили, можно и для х = 1 определить или даже скорее для 0<x<4, чтобы вообще быть точным, если вдруг захочется в будущем не только целочисленные корни искать


        1. akozyrenko Автор
          29.07.2024 12:08

          зачем определять 4 таких случая, загрязняя код, если все, кроме того, что обозначено с 0, и так отсеется на первом шаге цикла. Мы ничего не выиграем по скорости, а лишь только накрутим код.


          1. GeXoGeN
            29.07.2024 12:08
            +1

            не ещё четыре, а ещё один. раз в задаче требуется округление путём отбрасывания дробной части, то при 0<Х<4 ответ будет равен 1, можно сразу его вернуть. либо если вдруг мы захотели вычислять дробную часть тоже, то в этой ветке кода можно оставить правой границей Х и дальше просто продолжить выполнять тот же алгоритм.


          1. Proscrito
            29.07.2024 12:08

            Обработка частных и граничных случаев это не загрязнение, а очистка кода. Объем и чистота не синонимы.


    1. Sigest
      29.07.2024 12:08
      +1

      Умножение может и будет работать быстрее, я так понимаю вы предлагаете (mid*mid>x)? Там при больших числах mid (тест кейс с leetcode x=2147395599) будет переполнение и в бесконечный цикл уйдет, так как проверка будет работать неправильно. А если заморачиваться с BigInt, то может ну его нафик, пусть с делением работает


      1. GeXoGeN
        29.07.2024 12:08
        +1

        Mid*mid ни при каких условиях не будет больше максимального значения X. Я же не просто так написал, что начальную правую границу нужно сделать равной Х/2.

        Чето я наврал тут. Не читайте. Можно использовать uint.


        1. GeXoGeN
          29.07.2024 12:08
          +1

          Блин. Uint тоже не хватит. Всё, сдаюсь, посыпаю голову пеплом.


      1. wataru
        29.07.2024 12:08

        Там не надо BigInt, достаточно long long.


        1. Sigest
          29.07.2024 12:08

          Мы про джаву же. Что за long long? Ну ок, пусть будет джавовский Long. Но, во-первых по условию задачи (если смотреть инициализированный код на leetcode) вход и выход имеют тип int. Ок значит внутри алгоритма мы туда сюда преобразовываем из int в long и назад когда возвращаем результат. Во-вторых long-овая математика работает дольше int-овой. По сути мы ничего не выигрываем. Конкретно в данном случае.


          1. wataru
            29.07.2024 12:08

            Ок значит внутри алгоритма мы туда сюда преобразовываем из int в long и назад

            Целых 2 перобразования. На самом деле они вообще бесплатные для процессора. Ему без разницы, загрузить 64 бита в регистр EAX или 32 бита в AX.

            long-овая математика работает дольше int-овой.

            Не правда. Уже очень давно процессоры 64-битные. Там операции с Long и Int занимают одинаковое время.

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

            По сути мы ничего не выигрываем. Конкретно в данном случае.

            Выигрываем то, что умножение действительно быстрее деления. Довольно сильно.


            1. Sigest
              29.07.2024 12:08

              Не буду спорить. Скорее всего вы правы. Либо они одинаковы по скорости, либо очень не существенно. Я больше ориентировался не на битность процессора, а на его кеш. В голове очень давно , еще при изучении многопоточности, отложилось что в операциях с long на уровне процессора происходят оптимизации и в несинхронизированном коде (точнее без volatile) вполне реальна ситуация когда в одном потоке long переменная обновилась, а в другом потоке первые 32бита прочитались верно, а вторые закешировались и мы имеем вполне легальное, но неверное значение. Вот поэтому у меня такая мысль про long и закрепилась, что математика тут сложнее, чем с int


      1. Alexandroppolus
        29.07.2024 12:08

        если искать в пределах отрезка [0 ... 46340], то без переполнений уместится в int32


        1. akozyrenko Автор
          29.07.2024 12:08

          По условию задачи заданное число может быть в пределах отрезка от 0 до 2 в 31 степени - 1, поэтому mid выйдет за пределы при использовании умножения.


          1. wataru
            29.07.2024 12:08
            +1

            Но корень-то не может быть больше 46340.


            1. akozyrenko Автор
              29.07.2024 12:08

              ну, можете прогнать кейс с х = 2^31-1:), mid*mid вылетит за пределы int:)


              1. Alexandroppolus
                29.07.2024 12:08
                +1

                а вы не забыли сделать int right = min(x, 46340); ?


                1. akozyrenko Автор
                  29.07.2024 12:08

                  А как вы определили 46340 на этапе, когда мы записываем значение right, без использования встроенных функций?


                  1. Alexandroppolus
                    29.07.2024 12:08
                    +2

                    Посчитал на калькуляторе) Условие гарантирует, что прилетит число от 0 до 2^31-1, благодаря чему я могу с чистой совестью захардкодить signed int32 для типа параметра, и (что то же самое) число 46340.

                    Рамки выглядят искусственно, но от них не уйти. Этот ваш O(ln N) по факту справедлив только если N не больше какой-то константы, чтобы числа и операции с ними стоили O(1). Из-за чего весь алгоритм формально обесценивается до О(1). А для произвольной верхней границы N у нас будет длинная арифметика, числа длиной L = ln(N), стоимость умножения O(L * ln(L)), итого O(ln(N)^2 * ln(ln(N))) на всё про всё, если я нигде не ошибся. Тогда можно не заморачиваться на переполнения и ничего не хардкодить.


      1. VBDUnit
        29.07.2024 12:08

        Есть же специальная операция int32 × int32 = int64. Как раз для защиты от переполнений


  1. wataru
    29.07.2024 12:08
    +2

    Важно указать, что в условии просят решить задачу без использования встроенных функций извлечения корня или возведения в степень.

    Теперь вот вопрос: у вас в статье написано, что бинарный поиск работает на упорядоченном массиве. Но в задаче у вас никакого массива вообще нет. Как так?

    Ну и маленькое замечание else if (mid <= x / mid) - тут условие не нужно, ведь это ветка elsе в проверке обратного условия. Оно тут всегда выполняется.


    1. akozyrenko Автор
      29.07.2024 12:08

      Массива тут как таковой неочевиден, но фактически он есть. Ведь числа, которыми мы огибаем границы, как раз выстраиваются в упорядоченный массив (от 1 до Х)

      С остальными замечаниями согласна, поправила/добавила:)


      1. wataru
        29.07.2024 12:08
        +1

        Массива тут как таковой неочевиден, но фактически он есть.

        Я к тому, что этот момент стоит расписать поподробнее. Что на самом деле у вас есть мнимый массив, где по индексу i стоит число i^2. Ясно, что если найти в этом массиве самое большое число, не превосходящее x, то его индекс и будет ответом на задачу. Для большей ясности стоило бы задачу еще и формализовать (вроде: a \rightarrow min : a \in Z, a^2 \le x) Тогда понятно, что мы ищем в массиве.

        Вместо хранения массива его значения можно просто вычислять, когда они нужны, m*m в коде - это фактически array[m]. Кстати, хорошо бы описать, почему вы вместо m*m > x используете m > x/m - это же чтобы избежать переполнения, да?

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


        1. akozyrenko Автор
          29.07.2024 12:08

          Спасибо за фидбек.

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


  1. wataru
    29.07.2024 12:08

    Еще тонкий момент, а почему x=0 у вас крайний случай? Почему нельзя просто сделать l=0?


    1. akozyrenko Автор
      29.07.2024 12:08

      Если вы сделаете left = 0, то словите ArithmeticException для х от 0 до 3 включительно


      1. wataru
        29.07.2024 12:08
        +1

        Только потому что вы решили делить x на m в усорвии, вместо логичного m*m. И об этом надо было написать в тексте статьи. И вообще, этот частный случай - он из-за вот той вот детали реализации, поэтому его стоило бы рассмотреть в конце, а не начинать с него повествование.


  1. vadimr
    29.07.2024 12:08

    Идея понятна, но пример выбран неудачно. Быстрое вычисление квадратного корня – это очень хорошо исследованная задача, и её можно решить гораздо быстрее бинарного поиска.

    Например, вот тут люди обсуждают эту тему.


    1. GeXoGeN
      29.07.2024 12:08

      да с примером кажется вообще пофиг. ведь этим способом можно искать корни чуть ли не любого уравнения вообще.


      1. vadimr
        29.07.2024 12:08

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


        1. GeXoGeN
          29.07.2024 12:08
          +1

          настолько известный, что проходится в программе обычного технического ВУЗа даже не по IT-специальностям. по-моему даже ещё какие-то проходили, но этот лучше всего запомнился, как самый очевидный. там больше всего сложностей, когда корней больше чем один.


  1. NikolayTheSquid
    29.07.2024 12:08
    +2

    Можно вычислять серединным поиском значение любой непрерывной мотонной функции (логарифма, экпоненты и т.д.). Казалось бы, решение подобных простейших задач не заслуживает размазывания на целую статью. Стыдно, но такой уж нынче Хабр победивших продактов, эйчаров и прочих чертей, не знакомых со школьными истинами.