Сравнение алгоритмов поиска подстроки

Существуют несколько классических описанных в разных книгах и статьях алгоритмов, задача которых — это поиск некоторой строки в тексте (ещё говорят поиск подстроки в строке или же образца в строке). Я написал на Джава 17 реализации 4 разных алгоритмов, о которых рассказываю на лекциях, и посмотрел, как они работают на макбуке с процессором М1. Сразу скажу, что эти реализации не являются эталонными и наверняка их можно улучшить. Так, Бойер-Мур достаточно легко ускоряется за счёт замены самописаного OpenTable на что-то вроде Int2IntOpenHashMap Но, тем не менее, почему бы не посравнивать хоть что-то?

Итак, перед тем, как представить 4 алгоритма, участвующих в сравнении, и для всех них будет использоваться этот интерфейс:

public interface StringSearch {

    int find(String pattern, String string);

    default boolean equals(String pattern, String text, int from, int to) {
        for (int i = from; i < to; i++) {
            if (pattern.charAt(i - from) != text.charAt(i)) {
                return false;
            }
        }
        return true;
    }

}

А сами алгоритмы реализуем так:

public class NaiveSearch implements StringSearch {

    @Override
    public int find(String p, String s) {
        for (int i = 0; i <= s.length() - p.length(); i++) {
            if (equals(p, s, i, i + p.length())) {
                return i;
            }
        }
        return -1;
    }
}
public class RabinKarpSearch implements StringSearch {

    @Override
    public int find(String pattern, String string) {
        int patternLength = pattern.length();
        int hs = stringHashCode(pattern, 0, patternLength);
        int stringLength = string.length();
        int st = stringHashCode(string, 0, patternLength);
        for (int i = 0; i <= stringLength - patternLength; i++) {
            if (hs == st && equals(pattern, string, i, i + patternLength)) {
                return i;
            }
            st = st - string.charAt(i) + string.charAt(i + patternLength);
        }
        return -1;
    }

    private int stringHashCode(String string, int start, int end) {
        int result = 0;
        for (int i = start; i < end; i++) {
            result += string.charAt(i);
        }
        return result;
    }
}
public class BoyerMooreSearch implements StringSearch {

    @Override
    public int find(String p, String s) {
        OpenTable table = new OpenTable<>(p.length());
        for (int i = 0; i < p.length() - 1; i++) {
            table.put(p.charAt(i), i);
        }
        loop: for (int i = 0; i < s.length() - p.length() + 1;) {
            for (int j = p.length() - 1; j > -1; j--) {
                if (p.charAt(j) != s.charAt(i + j)) {
                    Integer move = table.get(s.charAt(i + j));
                    i += Math.max(1, j - (move == null ? p.length() : move));
                    continue loop;
                }
            }
            return i;
        }
        return -1;
    }
}
public class KnuthMorrisPrattSearch implements StringSearch {

    @Override
    public int find(String p, String s) {
        var pi = computePrefixFunction(p);
        var q = 0;
        for (int i = 0; i < s.length(); i++) {
            while (q > 0 && p.charAt(q) != s.charAt(i)) {
                q = pi[q];
            }
            if (p.charAt(q) == s.charAt(i)) {
                q = q + 1;
            }
            if (q == p.length()) {
                return i - p.length() + 1;
            }
        }

        return -1;
    }

    private int[] computePrefixFunction(String p) {
        int[] pi = new int[p.length() + 1];
        int k = 0;
        for (int q = 1; q < p.length(); q++) {
            while (k > 0 && p.charAt(k) != p.charAt(q)) {
                k = pi[k];
            }
            if (p.charAt(k) == p.charAt(q)) {
                k = k + 1;
            }
            pi[q + 1] = k;
        }
        return pi;
    }
}

Сам тест, который можно позапускать, лежит в проекте на Гитхабе.

Для тестирования я взял 4 разных текста:

  1. Классический текст Lorem ipsum (445 символов)
  2. Текст «Алисы в стране чудес» (152 173 символов)
  3. Последовательность ДНК (5872 символов)
  4. Весь код из одного моего проекта (227 055 символов)

План эксперимента

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

Эксперимент 1: Влияние длины подстроки на время работы

В этом эксперименте длина строки не меняется, а длина подстроки изменяется от 1 до 1 000 (в случае с текстом Lorem до 445). Теоретически, поскольку каждый алгоритм работает за линейное время (кроме наивной реализации) влияние n будет тем больше, чем ближе длина подстроки к длине самой строки. Запуск на MacBook Air M1 2020 дал следующие результаты:

Эксперимент 2: Влияние длины текста на время работы

Теперь будет менять длину текста. От 0 + длина образца до 5000 + длина образца (кроме Lorem), тогда как длина подстроки будет зафиксирована на значения 10, 256 и 1000.

Длина подстроки 10, текста от 10 до 5010:

Длина подстроки 256, текста от 256 до 5256:

Длина подстроки 1000, текста от 1000 до 6000:

Интересно то, что во всех случаях лучше всего работает самые простые алгоритмы: наивный и его улучшение в виде Рабина-Карпа. Причём последний работает лучше всего. Ещё лучше работает базовая реализация в самой Джаве (String::indexOf). Хотя и тут не без интересного: в эксперименте 1 можно увидеть, как на нескольких графиках время работы резко меняется тогда, когда длина паттерна становится равной 256. О причинах такого я могу догадываться, но попробую изучить вопрос внимательней и прийти с ответом в следующий раз.

Что касается эксперимента 2, то на коротких паттернах хуже всего работает алгоритм Бойера-Мура, но достаточно быстро он начинает реабилитироваться на паттернах в 256 и 1000 символах, за исключением текста с последовательностью ДНК. Это в общем-то согласуется с утверждением о том, что алгоритм Бойера-Мура не так эффективен на текстах с небольшим алфавитом.

Ссылки по теме

  1. Перформанс: что в имени тебе моём?
  2. Exact string matching algorithms
  3. Applied Comparison of String Matching Algorithms
  4. Exact string matching algorithms: Boyer-Moore and KMP
  5. java.lang.String Catechism

Худший вопрос в тесте — это построенный по типу: «Это может быть» с выбором неправильного варианта

Тест всегда должен предлагать выбор только правильных вариантов, а вопрос может быть переформулирован как «Это не может быть».

Короче, вот так вот не надо:

Эти небесные тела являются планетами

Выберите неверные ответы

  • Солнце
  • Земля

А надо так:

Эти небесные тела планетами не являются

  • Солнце
  • Земля

Задачи на JPoint 2018. Luxoft. День 2

Задача 1

public class Quiz_1 {

    public static void print() {
        System.out.print("A");
    }
    
    public static void main(String[] args) {
        ((Quiz_1) null).print();
        System.out.print("B");
    }
    
}

Варианты ответов:

  1. AB
  2. B
  3. NullPointerException
  4. Compilation error
Правильный ответ

1. AB

Из документации 15.12.4.1. Compute Target Reference (If Necessary) instanceof можно узнать, что вызов статического метода не требует вычисления ссылки на инстанс, поэтому никакой ошибки не произойдёт.


Задача 2

public class Quiz_2 {

    public void func(Integer i) {
        System.out.println("integer"); }
    public void func(Double i) {
        System.out.println("double"); }
    public void func(Object i) {
        System.out.println("object"); }

    public static void main(String[] args) {
        List nums = Arrays.asList(1/2);
        new Quiz_2().func(nums.get(0));
    }
}

Варианты ответов:

  1. integer
  2. double
  3. object
  4. Runtime exception
  5. Compilation error
Правильный ответ

3. object

… из-за того, что список List<?> nums типизирован <?>. Такой вайлдкард вернёт Object в любой ситуации, а компилятор заранее скомпилирует представленный код с вызовом правильной функции, а именно public void func(Object i) {.


Задача 3

public class Quiz_3 {

    public static void main(String[] args) {
        System.out.println(
                true?false:true == true?false:true
        );
    }
}

Варианты ответов:

  1. true
  2. false
  3. Compilation error
Правильный ответ

2. false

Тернарный оператор вычисляется слева направо.


Задача 4

public class Quiz_4 {

    public static void main(String[] args) {
        int n = 1;
        int result = Optional.of(n++)
                .map(i -> i + n)
                .orElse(-1);
        System.out.println(result);
    }
}

Варианты ответов:

  1. 2
  2. 3
  3. 4
  4. -1
  5. Runtime exception
  6. Compilaction error
Правильный ответ

6. Compilation error

Переменная n должна быть финальной, чтобы использоваться в лямбда-выражении i -> i + n. С другой стороны, n не может быть финальной из-за унарного оператора ++ в выражении Optional.of(n++).


Задача 5

public class Quiz_5 {

    public static void main(String[] args) {
        int n = Integer.MAX_VALUE;
        n++;
        System.out.println(n + n);
    }
}

Варианты ответов:

  1. -2
  2. 0
  3. 2
  4. 4294967296 (2^32)
  5. Runtime exception
  6. Compilation error
Правильный ответ

2. 0

Начнём с того, что Integer.MAX_VALUE + 1 = Integer.MIN_VALUE. В бинарном представлении значение Integer.MAX_VALUE (01111111111111111111111111111111) при добавлении к нему единицы получится Integer.MIN_VALUE (10000000000000000000000000000000). При сложении 2-х этих чисел сумма старших битов даст 102, что приведёт к переполнению в памяти. Поскольку 1 будет некуда записать в 32-х битном представлении, то останутся только нули.

Задачи на JPoint 2018. Luxoft. День 1

Задача 1

public class Quiz_1 {

    public static void main(String[] args) {
        String s = null;
        System.out.println(s instanceof String);
    }
    
}

Варианты ответов:

  1. true
  2. false
  3. NullPointerException
  4. Compilaction error
Правильный ответ

2. false

Согласно пункту документации 15.20.2. Type Comparison Operator instanceof:

At run time, the result of the instanceof operator is true if the value of the RelationalExpression is not null and the reference could be cast (§15.16) to the ReferenceType without raising a ClassCastException.


Задача 2

public class Quiz_2 {

    public static void main(String[] args) {
        String[] strings = {"1", "1"};
        Object[] objects = strings;
        objects[0] = 0;
        System.out.println(
                Arrays.toString(objects)
        );
    }
    
}

Варианты ответов:

  1. [0, 2]
  2. [1, 2]
  3. Runtime exception
  4. Compilation error
Правильный ответ

3. Runtime exception

Аналогичный пример даже указан в javadoc для класса java.lang.ArrayStoreException


Задача 3

public class Quiz_3 {

    public static void main(String[] args) {
        String str = "true";
        if ("t" + "r" + "u" + "e" == "true")
            System.out.print("1");
        if (str + "" == "true")
            System.out.print("2");
        if (str.replace('T', 't') == "true")
            System.out.print("3");
    }
    
}

Варианты ответов:

  1. 123
  2. 12
  3. 23
  4. 13
  5. 1
  6. 2
  7. 3
Правильный ответ

4. 13

  1. Выражение "t" + "r" + "u" + "e" является строковым литералом и согласно спецификации 3.10.5. String Literals:
    A long string literal can always be broken up into shorter pieces and written as a (possibly parenthesized) expression using the string concatenation operator +
    [...]
    Moreover, a string literal always refers to the same instance of class String.
    
        Strings computed by constant expressions (§15.28) are computed at compile time and then treated as if they were literals.
        Strings computed by concatenation at run-time are newly created and therefore distinct.
    

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

       L0
        LINENUMBER 15 L0
        LDC "true"
        ASTORE 1
       L1
        LINENUMBER 17 L1
        GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
        LDC "1"
        INVOKEVIRTUAL java/io/PrintStream.print (Ljava/lang/String;)V
    

    L0 соответствует сохарнению значения «true» в переменную, а L1 соответствует выводу «1» через System.out, любые проверки отсутствуют.

  2. Компилятор вместо оператора конкатенации «+» в байт-коде использует java.lang.StringBuilder:
       L2
        LINENUMBER 18 L2
        NEW java/lang/StringBuilder
        DUP
        INVOKESPECIAL java/lang/StringBuilder. ()V
        ALOAD 1
        INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
        LDC ""
        INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
        INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
        LDC "true"
        IF_ACMPNE L3
    

    Метод StringBuilder.toString() возвращает новый экземпляр типа String с новой ссылкой:

    @Override
    public String toString() {
        // Create a copy, don't share the array
        return new String(value, 0, count);
    }
    

    Именно поэтому сравнение через == вернёт значение false.

  3. В соответствии с javadoc метода replace():
    If the character {@code oldChar} does not occur in the character sequence represented by this {@code String} object, then a reference to this {@code String} object is returned.
    

    Поскольку 'T' отсутствует в исходной строке, то вернётся исходный объект.


Задача 4

public class Quiz_4 {

    public static void main(String[] args) {
        Stream stream =
                Stream.of("A", "B");
        System.out.print(1);
        stream.peek(System.out::print);
        System.out.print(2);
        stream.forEach(System.out::print);
    }
    
}

Варианты ответов:

  1. 1AB2AB
  2. 1A2A1B2B
  3. 12AB
  4. 12ABAB
  5. Runtime exception
  6. Compilation error
Правильный ответ

5. Runtime exception

А конкретно:

Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed
	at java.util.stream.AbstractPipeline.sourceStageSpliterator(AbstractPipeline.java:279)
	at java.util.stream.ReferencePipeline$Head.forEach(ReferencePipeline.java:580)
	at me.markoutte.sandbox.conferences.jpoint2018.luxoft.day1.Quiz_4.main(Quiz_4.java:21)

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


Задача 5

public class Quiz_5 {

    public static void main(String[] args) {
        HashSet set = new HashSet<>();
        for (short i = 0; i < 100; i++) {
            set.add(i);
            set.remove(i - 1);
        }
        System.out.println(set.size());
    }
    
}

Варианты ответов:

  1. 0
  2. 1
  3. 100
  4. Runtime exception
  5. Compilation error
Правильный ответ

3. 100

Ни одно из значений не будет удалено из сета, поскольку для операции (i - 1) будет автоматически выполнено сначала расширения типа до integer, а потом боксинг в тип Integer. Поскольку в сете хранится тип Short, а не Integer, то ни одна из операций по удалению не сможет найти нужных значений и величина массива будет равна количеству операции по добавлению. В данном случае таковых 100.

Коварный дабл

Вы и так это знали:

Тип double не очень точный тип данных

Решите, например, сегодняшнее задание на codewars:

Your task is to construct a building which will be a pile of n cubes. The cube at the bottom will have a volume of n^3, the cube above will have volume of (n-1)^3 and so on until the top which will have a volume of 1^3.

You are given the total volume m of the building. Being given m can you find the number n of cubes you will have to build?

The parameter of the function findNb (find_nb, find-nb, findNb) will be an integer m and you have to return the integer n such as n^3 + (n-1)^3 + … + 1^3 = m if such a n exists or -1 if there is no such n.

Examples:

findNb(1071225) --> 45
findNb(91716553919377) --> -1

https://www.codewars.com/kata/5592e3bd57b64d00f3000047

Переведу в кратце: необходимо вычислить количество кубиков для здания заданного объёма. Объём здания считается как сумма объёмов состовляющих его кубиков. Каждый кубик, начиная с нижнего, имеет объём (n - i + 1)^3, где i — порядковый номер куба (от 1 до n) снизу.

Решение в лоб с использованием циклов:

public static long findNb(long m) {
    long n = 0, totalVolume = 0;
    while (totalVolume < m) {
        totalVolume += ++n * n * n;
    }
    return totalVolume == m ? n : -1;
}

Но в университете мне что-то рассказывали про ряды и их суммы, поэтому захотелось решить задачу математически. Как считать ряды я давно забыл, но ответ вы найдёте, перейдя на Wolfram Alpha и введя sum n^3, n = 1 to k в поле поиска. Узнаем, что:

\sum_{n=1}^{k} n^3 = \frac{1}{4}k^2(k + 1)^2

Приравняем правую часть к m, извлечём корень и упростим выражение:

k^2 + k - 2\sqrt{m} = 0

Теперь можно решить квадратное уравнение:

x = \frac{\sqrt{1 + 8\sqrt{m}} - 1}{2}

Пишем код:

public static long findNb(long m) {
    double res = Math.sqrt(m);
    double d = 1 + 8 * res;
    double n = (Math.sqrt(d) - 1) / 2;
    return n - Math.floor(n) == 0 ? (long) n : -1;
}

Оказывается, что для некоторого числа тестовых данных, код даёт неверный ответ. Например, для значения 1646842954019151682L. В джаве корень из этого числа даёт:

System.out.println(String.format("%.30f", Math.sqrt(1646842954019151682L)));
// output 1283293791.000000000000000000000000000000

Тогда как правильный ответ:

1283293791.00000000038962239473657673134031176401122912...

Очевидно, что ответы не совпадают. Fin!

Вывод

Более правильное (и, наверняка, быстрое) решение не всегда даёт правильные результаты. Этим не удивишь опытного разработчика, но начинающие программисты долго могут гадать о природе ошибки.

И напоследок. Следующее выражение истинно:

System.out.println(1646842954019151681L == 1646842954019151682D);

а это ложно:

System.out.println(1646842954019151681L == (long) 1646842954019151682D);

Ещё бонус

Посчитать правильно предыдущий пример всё-таки можно, если использовать более подходищие типы для таких задач. Речь про BigDecimal:

public static long findNb(long m) {
    MathContext mc = new MathContext(64);
    BigDecimal res = BigDecimal.valueOf(m).sqrt(mc);
    BigDecimal d = res.multiply(BigDecimal.valueOf(8)).add(BigDecimal.ONE);
    BigDecimal n = d.sqrt(mc).subtract(BigDecimal.ONE).divide(BigDecimal.valueOf(2));
    return Objects.equals(n, n.setScale(0, RoundingMode.FLOOR)) ? n.longValue() : -1;
}

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: