Escribimos la búsqueda de subcadenas mejor que en los libros de texto



La vida de un ingeniero está llena de sorpresas: especialmente cuando tienes que lidiar con la productividad. Por ejemplo, ¿qué sucede si intenta ejecutar este fragmento de código Java? Se ve bastante inocente:

//   String.repeat  JDK 11  :
final var needle = "A".repeat(500000) + "B";
final var haystack = "A".repeat(1000000) + "B";
System.out.println(haystack.indexOf(needle));

Esperamos, esperamos, esperamos ... Al menos en mi computadora portátil 2015 OpenJDK 13, encontrar una aguja en un pajar lleva aproximadamente un minuto. Nuestro viejo JVM ha pasado por décadas de ajuste de rendimiento, ha implementado de manera efectiva intrínsecos String.indexOfy más. ¿Qué pudo haber salido mal?
Este es el comienzo de una serie de varios artículos por cortesía de su autor, Linas Medžiūnas , y originalmente publicados en el blog de Ingeniería de WiX .


Eche un vistazo más de cerca a lo que se ingresa: los datos se seleccionan especialmente para lograr un rendimiento cuadrático en el peor de los casos ( O(nm)donde nestá la longitud haystacky mla longitud needle) para el ingenuo algoritmo de búsqueda de subcadenas. Repasamos todos los caracteres haystack, y si coinciden con los primeros caracteres needle, comenzamos a correr needleen el bucle interno, y así sucesivamente hasta el primer carácter no coincidente.

Puede argumentar que este ejemplo es inútil, ya que dichos datos de entrada fueron diseñados y archivados especialmente, en la práctica no encontrará esto. Pensar dos veces. ¿Qué sucede si está trabajando en un servicio web cuyos usuarios pueden cargar cadenas arbitrarias, y en algún lugar en la parte posterior del servicio hay un código que se ejecutaindexOfen estas lineas? Luego, solo unas pocas solicitudes maliciosas como la anterior pondrán su servicio de rodillas. Vale la pena saber, al menos, sobre los peores casos para los datos de entrada.

Afortunadamente, existen algoritmos de búsqueda de subcadenas que tienen complejidad lineal ( O(n+m)). No tienen problemas con los datos del ejemplo anterior. Por ejemplo, el siguiente código Scala hace lo mismo, pero se ejecuta en milisegundos en la misma computadora, la misma JVM y usa exactamente lo mismo bajo el capó java.lang.String:

val needle = "A" * 500000 + "B"
val haystack = "A" * 1000000 + "B"
println(haystack.indexOfSlice(needle))

El secreto de la gran diferencia está dentro del método indexOfSlice, que forma parte de la biblioteca estándar de Scala . Implementa el inteligente algoritmo lineal Knut-Morris-Pratt . Y no, no estoy diciendo que el lenguaje X sea mejor que el lenguaje Y. ¡Desafortunadamente, aquí todo es mucho más complicado! Por ejemplo, indexOfSliceen Scala, este es un método generalizado que funciona no solo con cadenas, sino también en otras colecciones secuenciales, y puede comparar no solo caracteres, sino también elementos de otros tipos. Debería ser mucho más lento queString.indexOfde Java en el caso del medio (hablaremos de esto más adelante). Por lo tanto, tenemos un algoritmo eficiente con un rendimiento mucho mejor en el peor de los casos, pero en promedio es más lento porque tiene una parte constante mucho más grande. Dilemas como este son un problema típico en el rendimiento del ajuste. No existe una píldora mágica que resuelva todos los problemas: debe analizar cuidadosamente el problema y hacer los micro-puntos de referencia correctos.



Sigues conmigo ¡Bueno! Verás, esto es solo una introducción. Quería motivarte a lidiar con la complejidad teórica y el rendimiento práctico de los algoritmos. En el resto de este artículo, veremos algunas implementaciones de varios algoritmos de búsqueda de subcadenas y sus puntos de referencia.

Exploraremos tres algoritmos de búsqueda de subcadenas. Todos ellos trabajan en tiempo lineal y requieren preprocesamiento, que depende linealmente de la longitud needle. El cálculo de lo mismo needlese requiere solo una vez, y luego se puede reutilizar en varios intentos de búsqueda. Esto es razonable, porque en muchos casos necesitamos buscar la misma línea una y otra vez. E incluso si no hacemos esto, la precomputación no es una operación particularmente costosa.

Todos los algoritmos a continuación omiten cada uno de los caracteres enhaystacksolo una vez en una fila (sin acceso aleatorio por índice), por lo que todos funcionan bien en modo de transmisión. Este artículo surgió durante un trabajo real en un servidor proxy para producción basado en el marco Netty , y esto influyó en algunas de las decisiones de diseño de API. Además, dado que necesitábamos hacer una búsqueda en buffers de bytes, el código funcionará con Byte, no con Char.



Knut-Morris-Pratt (algoritmo KMP)


Este es un conocido algoritmo de búsqueda de subcadenas que data de los años 70 del siglo pasado. Está bien descrito en la literatura , por lo que no lo describiré aquí en detalle. El ILC se basa en máquinas de estado : durante la fase de cálculo preliminar, se construye una matriz de índices de enlace a partir de needle. Durante la búsqueda, la máquina acepta caracteres haystackuno por uno en la entrada y actualiza su estado interno en consecuencia (y el estado de que solo hay un índice en la tabla de relaciones).

Aquí hay una implementación en Scala .

Algoritmo de búsqueda de subcadena binaria


Inicialmente, tuve que inventar independientemente el nombre de este algoritmo: nunca había visto algo así en ninguna parte de la literatura. Como resultado, llegué al nombre de "Shifting Bit Mask". Más tarde resultó que este algoritmo y sus variaciones se conocen desde 1964 bajo varios nombres ingleses como "Bitap", "Shift-or", "Shift-and", "Baeza-Yates - Gonnet". Gracias a los lectores que lo han encontrado por mí. Este artículo fue escrito mucho antes de esta noticia.

Este algoritmo se basa en una idea muy simple y funciona muy bien, ya que casi no hay saltos, y se basa en varias operaciones binarias primitivas. Debido a esto, tiene un límite en la longitud needleque vamos a buscar: no puede tener más de 64 bytes. Este número fue tomado simplemente por el número de bits enLongen la JVM Esta limitación es lo suficientemente generosa para una gran cantidad de tareas reales.

Como desarrollé originalmente este algoritmo, intentaré hablar sobre él con más detalle. Primero, calculamos previamente el contexto de búsqueda para el deseado needle:

  def computeBitMasks(needle: Array[Byte]): Array[Long] = {
    require(needle.length <= 64, "Maximum supported search pattern length is 64.")
    val bitMasks = Array.ofDim[Long](256)
    var bit = 1L
    for (c <- needle) {
      bitMasks(toUnsignedInt(c)) |= bit
      bit <<= 1
    }
    bitMasks
  }

Calculamos previamente bitMask(64 bits Long) para cada valor de byte posible (256 piezas bitMask). Para algún valor de byte X, bitmaskcontiene unidades en todos los lugares donde se Xencuentra needle. Por ejemplo, aquí hay una máscara de bits para la cadena "abracadabra": además, debe realizar un cálculo previo , lo que ayudará a comprender que encontramos una coincidencia exacta. Parece un valor , con un poco en posición :



successBitMaskLong1needle.length — 1

  def computeSuccessBitMask(needle: Array[Byte]): Long = {
    1L << (needle.length - 1)
  }

Y finalmente, debes hacer, de hecho, una búsqueda. El único estado mutable que queremos almacenar es currentMask( Long). Para cada byte en, haystacknos desplazamos currentMaskun 1bit hacia la izquierda , establecemos su bit menos significativo 1y hacemos un bit a bit andentre el resultado y bitMask, calculado para el valor de byte procesado actual de haystack(esto andborra todos los bits en aquellos lugares currentMaskque no coinciden con el byte procesado actual).

Por lo tanto, después de procesar cada byte, solo sobrevivirán aquellos bits que estén en posiciones adecuadas. Y con cada byte procesado, todos los bits se desplazan a la izquierda en una posición. Si el bit "sobrevive" durante el número de iteraciones igual a la longitudneedle- ¡Encontramos una coincidencia! Y podemos verificar esto con successBitMask:

  def process(value: Byte): Boolean = {
    currentMask = ((currentMask << 1) | 1) & bitMasks(toUnsignedInt(value))
    (currentMask & successBitMask) == 0
  }

Nota: el método descrito anteriormente devuelve falsesi se encuentra algo y parece contradictorio. Esto se puede entender para que el valor truesignifique la necesidad de continuar la búsqueda, pero lo falsedetiene, esto se debe al hecho de que, como escribí anteriormente, la API se hizo compatible con Netty. Si se pregunta cómo ejecutar una búsqueda, aquí hay un ejemplo.

Como resultado, toda la lógica se reduce a unas pocas instrucciones simples del procesador. Desafortunadamente, sigue habiendo una comprobación completamente inútil de los límites de los índices de la matriz bitMasks, que ningún JDK puede eliminar (y miré el ensamblador generado por varios JDK diferentes).

Aquí está la implementación completa en Scala .

Aho korasik


Este es otro algoritmo popular conocido desde 1975. Su característica distintiva (y a veces bastante útil) es la capacidad de buscar varios needlea la vez, mientras que todos los personajes haystackse omiten exactamente una vez (¡creo que es genial!). La idea de que todo esto funciona es una extensión del algoritmo KMP, una máquina de estados finitos que utiliza un árbol de prefijos (que se basa en varios needle), que contiene enlaces a enlaces (compárelos con una matriz unidimensional del KMP). Basado en estos enlaces, el estado interno del autómata se cambia entre los nodos del árbol de prefijos después de cada símbolo procesado, y algunos de los nodos indican un resultado de búsqueda positivo para un determinadoneedle. La fase de precomputación aquí es bastante complicada, pero la fase de búsqueda es inesperadamente muy simple.

Aquí hay un enlace a una implementación funcional en Scala .



Esta era una lista completamente incompleta de algoritmos de búsqueda de subcadenas. También probamos el algoritmo Rabin-Karp y el algoritmo Boyer-Moore . De estos dos, Boyer-Moore mostró un rendimiento comparable, pero ambos no son compatibles con la transmisión (utilizando acceso aleatorio haystackpor índice), por lo que los descarté de esta investigación.



Puntos de referencia


Vamos a comparar los tres algoritmos descritos anteriormente y, además, veremos los resultados de los métodos String.indexOf(Java) y indexOfSlice(Scala). Para ser honesto, esta no es una comparación completamente correcta, porque String.indexOffunciona con cadenas y todos los demás métodos están en matrices de bytes. Pero esto no parece invalidar los resultados de tal comparación. Además, también Bytes.indexOfincluí los resultados de Guava (v.28.1). Este método funciona en conjuntos de bytes. Y lo escribieron en Google: todo lo que escriben allí funciona muy rápido, ¿verdad?

Escribir puntos de referencia siempre es difícil, porque puede enviar datos completamente diferentes a la entrada, cambiarlos de muchas maneras diferentes, no solo en longitud needleyhaystack, pero también por el contenido interno de estas líneas (que pueden afectar en gran medida a algunos algoritmos). En la práctica, siempre vale la pena verificar los datos de entrada que son más similares a los datos de sus tareas reales (esto es lo que hicimos en nuestro proyecto).

Para acortar este artículo, utilicé solo 2 tipos de entrada. Uno de ellos está destinado a reflejar el caso real: haystackaproximadamente 1,5 KB de tamaño (con texto legible por humanos dentro) needle- 9 bytes, y no en haystackesta secuencia (esto es necesario para forzar al algoritmo a realizar un escaneo completo).

Se necesita otro tipo de entrada para obtener el peor comportamiento de un algoritmo cuadrático. Es mucho más corto que los datos desde el comienzo de este artículo: de lo contrario tendríamos que esperar un minuto entero, ¿recuerdas? Formaciónhaystackse establece en el formato "AA...AAB"(la misma longitud que el primer tipo de datos) y needle- 64 bytes (especialmente para el algoritmo de búsqueda de subcadenas binarias para hacer frente a él) una matriz del mismo tipo (la coincidencia es solo al final haystack).

Un punto de referencia escrito en el marco JMH se puede encontrar aquí . Si tiene otras ideas sobre qué y cómo medir aquí, puede clonar este repositorio, cambiar algo y publicar comentarios.

A sugerencia de Vladimir Sitnikov , agregué resultados de referencia para java.util.regex.Pattern; él usa el algoritmo Boyer-Moore bajo el capó.


(Nota del traductor: por cierto, Vladimir Sitnikov es miembro de varios comités de programa en el Grupo JUG Ru y hace informes interesantes. Por ejemplo, un video de su informe de JPoint 2019 titulado "Java se ralentiza: edición CodeCache" está disponible en el enlace ).

Resultados de referencia


Los resultados se dan en milisegundos, menos es mejor: aquí todo es como se esperaba:

# JMH version: 1.21
# VM version: JDK 13.0.1, OpenJDK 64-Bit Server VM, 13.0.1+9
Benchmark (searchInput) Mode Cnt Score Error Units
javaIndexOf REGULAR avgt 5 0.622 ± 0.002 us/op
shiftingBitMask REGULAR avgt 5 1.982 ± 0.017 us/op
regexPattern REGULAR avgt 5 2.184 ± 0.006 us/op
kmp REGULAR avgt 5 2.635 ± 0.016 us/op
scalaIndexOfSlice REGULAR avgt 5 3.202 ± 0.009 us/op
guavaIndexOf REGULAR avgt 5 3.696 ± 0.095 us/op
ahoCorasic REGULAR avgt 5 7.063 ± 0.040 us/op
shiftingBitMask WORST_CASE avgt 5 1.986 ± 0.010 us/op
kmp WORST_CASE avgt 5 5.120 ± 0.006 us/op
ahoCorasic WORST_CASE avgt 5 6.892 ± 0.025 us/op
scalaIndexOfSlice WORST_CASE avgt 5 8.765 ± 0.007 us/op
regexPattern WORST_CASE avgt 5 11.566 ± 0.086 us/op
javaIndexOf WORST_CASE avgt 5 23.029 ± 0.124 us/op
guavaIndexOf WORST_CASE avgt 5 52.927 ± 0.275 us/op



  • Para los datos ordinarios, domina javaIndexOf, porque utiliza intrínsecos de alto rendimiento en su interior, por lo que la parte constante es pequeña;
  • , : , (O(nm)) javaIndexOf, — , shiftingBitMask ( ) .
  • guavaIndexOf , javaIndexOf; , 2 , shiftingBitMask;
  • scalaIndexOfSlice - , knuthMorrisPratt, , — , ;
  • el rendimiento no es la característica más fuerte ahoCorasic(o al menos de su implementación; debo admitir que realmente no intenté hacer microoptimizaciones en él, porque lo agregué solo por su característica distintiva: la capacidad de buscar en varias líneas a la vez, y esto similar al tema para un artículo separado);
  • los datos de entrada (y la longitud needle) no afectaron el rendimiento shiftingBitMasky ahoCorasic.

recomendaciones


En diferentes casos, los puntos de referencia pueden funcionar de diferentes maneras. A pesar de que los resultados anteriores parecen muy indicativos, siempre debe tomar medidas usted mismo y con datos que reflejen sus tareas reales.

En base a los datos presentados, hice las siguientes conclusiones:

  • String- , , String.indexOf ( java.util.regex.Pattern — );
  • , needle 64 , ;
  • , --;
  • Scala - ( ), indexOfSlice — ;
  • , -.

¡Eso es todo! Si le gusta leer sobre algoritmos, rendimiento y similares (y también sobre Scala, JVM y Java en general), suscríbase al autor de este artículo, Linas Medziunas ( Medio , Twitter ).

El repositorio de github con todo el código en este artículo está aquí .



Las traducciones de los artículos se publican con el apoyo del Grupo JUG Ru y la Conferencia JPoint .


All Articles