Java Reemplazo de varias subcadenas diferentes en una cadena a la vez (o de la manera más eficiente)

97

Necesito reemplazar muchas subcadenas diferentes en una cadena de la manera más eficiente. ¿Hay otra forma que no sea la forma de fuerza bruta de reemplazar cada campo usando string.replace?

Yossale
fuente

Respuestas:

102

Si la cadena en la que está operando es muy larga, o si está operando con muchas cadenas, entonces podría valer la pena usar un java.util.regex.Matcher (esto requiere tiempo por adelantado para compilar, por lo que no será eficiente si su entrada es muy pequeña o su patrón de búsqueda cambia con frecuencia).

A continuación se muestra un ejemplo completo, basado en una lista de tokens extraídos de un mapa. (Utiliza StringUtils de Apache Commons Lang).

Map<String,String> tokens = new HashMap<String,String>();
tokens.put("cat", "Garfield");
tokens.put("beverage", "coffee");

String template = "%cat% really needs some %beverage%.";

// Create pattern of the format "%(cat|beverage)%"
String patternString = "%(" + StringUtils.join(tokens.keySet(), "|") + ")%";
Pattern pattern = Pattern.compile(patternString);
Matcher matcher = pattern.matcher(template);

StringBuffer sb = new StringBuffer();
while(matcher.find()) {
    matcher.appendReplacement(sb, tokens.get(matcher.group(1)));
}
matcher.appendTail(sb);

System.out.println(sb.toString());

Una vez que se compila la expresión regular, escanear la cadena de entrada generalmente es muy rápido (aunque si su expresión regular es compleja o implica retroceder, ¡aún necesitaría comparar para confirmar esto!)

Todd Owen
fuente
1
Sí, sin embargo, debe ser evaluado por el número de iteraciones.
techzen
5
Creo que debería evitar los caracteres especiales en cada token antes de hacerlo"%(" + StringUtils.join(tokens.keySet(), "|") + ")%";
Desarrollador Marius Žilėnas
Tenga en cuenta que se puede usar StringBuilder para obtener un poco más de velocidad. StringBuilder no está sincronizado. editar whoops solo funciona con java 9, aunque
Tinus Tate
3
Lector futuro: para expresiones regulares, "(" y ")" incluirán el grupo para buscar. El "%" cuenta como literal en el texto. Si sus términos no comienzan Y terminan con el "%", no se encontrarán. Por lo tanto, ajuste los prefijos y sufijos en ambas partes (texto + código).
linuxunil
66

Algoritmo

Una de las formas más eficientes de reemplazar cadenas coincidentes (sin expresiones regulares) es usar el algoritmo Aho-Corasick con un Trie eficaz (pronunciado "try"), algoritmo hash rápido e implementación eficiente de colecciones .

Código simple

Una solución simple aprovecha Apache de la StringUtils.replaceEachsiguiente manera:

  private String testStringUtils(
    final String text, final Map<String, String> definitions ) {
    final String[] keys = keys( definitions );
    final String[] values = values( definitions );

    return StringUtils.replaceEach( text, keys, values );
  }

Esto se ralentiza en textos grandes.

Código rápido

La implementación de Bor del algoritmo Aho-Corasick introduce un poco más de complejidad que se convierte en un detalle de implementación al usar una fachada con la misma firma de método:

  private String testBorAhoCorasick(
    final String text, final Map<String, String> definitions ) {
    // Create a buffer sufficiently large that re-allocations are minimized.
    final StringBuilder sb = new StringBuilder( text.length() << 1 );

    final TrieBuilder builder = Trie.builder();
    builder.onlyWholeWords();
    builder.removeOverlaps();

    final String[] keys = keys( definitions );

    for( final String key : keys ) {
      builder.addKeyword( key );
    }

    final Trie trie = builder.build();
    final Collection<Emit> emits = trie.parseText( text );

    int prevIndex = 0;

    for( final Emit emit : emits ) {
      final int matchIndex = emit.getStart();

      sb.append( text.substring( prevIndex, matchIndex ) );
      sb.append( definitions.get( emit.getKeyword() ) );
      prevIndex = emit.getEnd() + 1;
    }

    // Add the remainder of the string (contains no more matches).
    sb.append( text.substring( prevIndex ) );

    return sb.toString();
  }

Benchmarks

Para los puntos de referencia, el búfer se creó utilizando randomNumeric de la siguiente manera:

  private final static int TEXT_SIZE = 1000;
  private final static int MATCHES_DIVISOR = 10;

  private final static StringBuilder SOURCE
    = new StringBuilder( randomNumeric( TEXT_SIZE ) );

Donde MATCHES_DIVISORdicta el número de variables a inyectar:

  private void injectVariables( final Map<String, String> definitions ) {
    for( int i = (SOURCE.length() / MATCHES_DIVISOR) + 1; i > 0; i-- ) {
      final int r = current().nextInt( 1, SOURCE.length() );
      SOURCE.insert( r, randomKey( definitions ) );
    }
  }

El código de referencia en sí ( JMH parecía excesivo):

long duration = System.nanoTime();
final String result = testBorAhoCorasick( text, definitions );
duration = System.nanoTime() - duration;
System.out.println( elapsed( duration ) );

1.000.000: 1.000

Un micro-benchmark simple con 1,000,000 de caracteres y 1,000 cadenas colocadas al azar para reemplazar.

  • testStringUtils: 25 segundos, 25533 milis
  • testBorAhoCorasick: 0 segundos, 68 milis

No contestar.

10,000: 1,000

Usando 10,000 caracteres y 1,000 cadenas coincidentes para reemplazar:

  • testStringUtils: 1 segundo, 1402 milis
  • testBorAhoCorasick: 0 segundos, 37 milis

La división se cierra.

1.000: 10

Usando 1,000 caracteres y 10 cadenas coincidentes para reemplazar:

  • testStringUtils: 0 segundos, 7 milis
  • testBorAhoCorasick: 0 segundos, 19 milis

Para cadenas cortas, la sobrecarga de configurar Aho-Corasick eclipsa el enfoque de fuerza bruta StringUtils.replaceEach.

Es posible un enfoque híbrido basado en la longitud del texto para obtener lo mejor de ambas implementaciones.

Implementaciones

Considere comparar otras implementaciones para texto de más de 1 MB, que incluyen:

Documentos

Artículos e información relacionada con el algoritmo:

Dave Jarvis
fuente
5
Felicitaciones por actualizar esta pregunta con nueva información valiosa, eso es muy bueno. Creo que un punto de referencia JMH sigue siendo apropiado, al menos para valores razonables como 10,000: 1,000 y 1,000: 10 (el JIT puede hacer optimizaciones mágicas a veces).
Tunaki
elimine builder.onlyWholeWords () y funcionará de manera similar a la sustitución de cadenas.
Ondrej Sotolar
Muchas gracias por esta excelente respuesta. ¡Esto definitivamente es muy útil! Solo quería comentar que para comparar los dos enfoques, y también para dar un ejemplo más significativo, se debe construir el Trie solo una vez en el segundo enfoque y aplicarlo a muchas cadenas de entrada diferentes. Creo que esta es la principal ventaja de tener acceso a Trie versus StringUtils: solo lo compila una vez. Aún así, muchas gracias por esta respuesta. Comparte muy bien la metodología para implementar el segundo enfoque
Vic Seedoubleyew
Un excelente punto, @VicSeedoubleyew. ¿Quieres actualizar la respuesta?
Dave Jarvis
9

Esto funcionó para mí:

String result = input.replaceAll("string1|string2|string3","replacementString");

Ejemplo:

String input = "applemangobananaarefruits";
String result = input.replaceAll("mango|are|ts","-");
System.out.println(result);

Salida: manzana-plátano-frui-

bikram
fuente
Exactamente lo que necesitaba mi amigo :)
GOXR3PLUS
7

Si va a cambiar un String muchas veces, entonces generalmente es más eficiente usar un StringBuilder (pero mida su rendimiento para averiguarlo) :

String str = "The rain in Spain falls mainly on the plain";
StringBuilder sb = new StringBuilder(str);
// do your replacing in sb - although you'll find this trickier than simply using String
String newStr = sb.toString();

Cada vez que realiza un reemplazo en un String, se crea un nuevo objeto String, porque los Strings son inmutables. StringBuilder es mutable, es decir, se puede cambiar tanto como desee.

Steve McLeod
fuente
Me temo que no ayuda. Siempre que el reemplazo difiera del original en longitud, necesitará algunos cambios, lo que puede ser más costoso que construir la cuerda de nuevo. ¿O me estoy perdiendo algo?
maaartinus
4

StringBuilderrealizará el reemplazo de manera más eficiente, ya que su búfer de matriz de caracteres se puede especificar con la longitud requerida. StringBuilderestá diseñado para más que agregar!

Por supuesto, la verdadera pregunta es si esto es una optimización demasiado lejos. La JVM es muy buena para manejar la creación de múltiples objetos y la posterior recolección de basura, y como todas las preguntas de optimización, mi primera pregunta es si ha medido esto y ha determinado que es un problema.

Brian Agnew
fuente
2

¿Qué tal usar el método replaceAll () ?

Avi
fuente
4
Se pueden manejar muchas subcadenas diferentes en una expresión regular (/substring1|substring2|.../). Todo depende de qué tipo de reemplazo esté intentando hacer el OP.
Avi
4
El OP está buscando algo más eficiente questr.replaceAll(search1, replace1).replaceAll(search2, replace2).replaceAll(search3, replace3).replaceAll(search4, replace4)
Kip
2

Rythm, un motor de plantillas de Java ahora lanzado con una nueva característica llamada Modo de interpolación de cadenas que le permite hacer algo como:

String result = Rythm.render("@name is inviting you", "Diana");

El caso anterior muestra que puede pasar un argumento a una plantilla por posición. Rythm también le permite pasar argumentos por nombre:

Map<String, Object> args = new HashMap<String, Object>();
args.put("title", "Mr.");
args.put("name", "John");
String result = Rythm.render("Hello @title @name", args);

Tenga en cuenta que Rythm es MUY RÁPIDO, alrededor de 2 a 3 veces más rápido que String.format y velocity, ya que compila la plantilla en código de bytes de Java, el rendimiento en tiempo de ejecución está muy cerca de la concatenación con StringBuilder.

Enlaces:

Gelin Luo
fuente
Esta es una capacidad muy antigua disponible con numerosos lenguajes de plantillas como velocidad, incluso JSP. Además, no responde a la pregunta que no requiere que las cadenas de búsqueda estén en ningún formato predefinido.
Angsuman Chakraborty
Interesante, la respuesta aceptada proporciona un ejemplo: "%cat% really needs some %beverage%."; ¿no es ese %token separado un formato predefinido? Su primer punto es aún más divertido, JDK proporciona muchas "capacidades antiguas", algunas de ellas comienzan en los 90, ¿por qué la gente se molesta en usarlas? Sus comentarios y votaciones negativas no tienen ningún sentido real
Gelin Luo
¿Cuál es el punto de introducir el motor de plantillas Rythm cuando ya hay muchos motores de plantillas preexistentes y ampliamente utilizados como Velocity o Freemarker para arrancar? Además, ¿por qué introducir otro producto cuando las funcionalidades básicas de Java son más que suficientes? Realmente dudo de su declaración sobre el rendimiento porque Pattern también se puede compilar. Me encantaría ver algunos números reales.
Angsuman Chakraborty
Verde, estás perdiendo el punto. El interrogador desea reemplazar cadenas arbitrarias, mientras que su solución reemplazará solo cadenas en formato predefinido como @ precedido. Sí, el ejemplo usa% pero solo como ejemplo, no como factor limitante. Entonces tu respuesta no responde a la pregunta y, por lo tanto, al punto negativo.
Angsuman Chakraborty
2

Lo siguiente se basa en la respuesta de Todd Owen . Esa solución tiene el problema de que si los reemplazos contienen caracteres que tienen un significado especial en expresiones regulares, puede obtener resultados inesperados. También quería poder hacer opcionalmente una búsqueda que no distinga entre mayúsculas y minúsculas. Esto es lo que se me ocurrió:

/**
 * Performs simultaneous search/replace of multiple strings. Case Sensitive!
 */
public String replaceMultiple(String target, Map<String, String> replacements) {
  return replaceMultiple(target, replacements, true);
}

/**
 * Performs simultaneous search/replace of multiple strings.
 * 
 * @param target        string to perform replacements on.
 * @param replacements  map where key represents value to search for, and value represents replacem
 * @param caseSensitive whether or not the search is case-sensitive.
 * @return replaced string
 */
public String replaceMultiple(String target, Map<String, String> replacements, boolean caseSensitive) {
  if(target == null || "".equals(target) || replacements == null || replacements.size() == 0)
    return target;

  //if we are doing case-insensitive replacements, we need to make the map case-insensitive--make a new map with all-lower-case keys
  if(!caseSensitive) {
    Map<String, String> altReplacements = new HashMap<String, String>(replacements.size());
    for(String key : replacements.keySet())
      altReplacements.put(key.toLowerCase(), replacements.get(key));

    replacements = altReplacements;
  }

  StringBuilder patternString = new StringBuilder();
  if(!caseSensitive)
    patternString.append("(?i)");

  patternString.append('(');
  boolean first = true;
  for(String key : replacements.keySet()) {
    if(first)
      first = false;
    else
      patternString.append('|');

    patternString.append(Pattern.quote(key));
  }
  patternString.append(')');

  Pattern pattern = Pattern.compile(patternString.toString());
  Matcher matcher = pattern.matcher(target);

  StringBuffer res = new StringBuffer();
  while(matcher.find()) {
    String match = matcher.group(1);
    if(!caseSensitive)
      match = match.toLowerCase();
    matcher.appendReplacement(res, replacements.get(match));
  }
  matcher.appendTail(res);

  return res.toString();
}

Aquí están mis casos de prueba unitarios:

@Test
public void replaceMultipleTest() {
  assertNull(ExtStringUtils.replaceMultiple(null, null));
  assertNull(ExtStringUtils.replaceMultiple(null, Collections.<String, String>emptyMap()));
  assertEquals("", ExtStringUtils.replaceMultiple("", null));
  assertEquals("", ExtStringUtils.replaceMultiple("", Collections.<String, String>emptyMap()));

  assertEquals("folks, we are not sane anymore. with me, i promise you, we will burn in flames", ExtStringUtils.replaceMultiple("folks, we are not winning anymore. with me, i promise you, we will win big league", makeMap("win big league", "burn in flames", "winning", "sane")));

  assertEquals("bcaacbbcaacb", ExtStringUtils.replaceMultiple("abccbaabccba", makeMap("a", "b", "b", "c", "c", "a")));
  assertEquals("bcaCBAbcCCBb", ExtStringUtils.replaceMultiple("abcCBAabCCBa", makeMap("a", "b", "b", "c", "c", "a")));
  assertEquals("bcaacbbcaacb", ExtStringUtils.replaceMultiple("abcCBAabCCBa", makeMap("a", "b", "b", "c", "c", "a"), false));

  assertEquals("c colon  backslash temp backslash  star  dot  star ", ExtStringUtils.replaceMultiple("c:\\temp\\*.*", makeMap(".", " dot ", ":", " colon ", "\\", " backslash ", "*", " star "), false));
}

private Map<String, String> makeMap(String ... vals) {
  Map<String, String> map = new HashMap<String, String>(vals.length / 2);
  for(int i = 1; i < vals.length; i+= 2)
    map.put(vals[i-1], vals[i]);
  return map;
}
Dormir
fuente
1
public String replace(String input, Map<String, String> pairs) {
  // Reverse lexic-order of keys is good enough for most cases,
  // as it puts longer words before their prefixes ("tool" before "too").
  // However, there are corner cases, which this algorithm doesn't handle
  // no matter what order of keys you choose, eg. it fails to match "edit"
  // before "bed" in "..bedit.." because "bed" appears first in the input,
  // but "edit" may be the desired longer match. Depends which you prefer.
  final Map<String, String> sorted = 
      new TreeMap<String, String>(Collections.reverseOrder());
  sorted.putAll(pairs);
  final String[] keys = sorted.keySet().toArray(new String[sorted.size()]);
  final String[] vals = sorted.values().toArray(new String[sorted.size()]);
  final int lo = 0, hi = input.length();
  final StringBuilder result = new StringBuilder();
  int s = lo;
  for (int i = s; i < hi; i++) {
    for (int p = 0; p < keys.length; p++) {
      if (input.regionMatches(i, keys[p], 0, keys[p].length())) {
        /* TODO: check for "edit", if this is "bed" in "..bedit.." case,
         * i.e. look ahead for all prioritized/longer keys starting within
         * the current match region; iff found, then ignore match ("bed")
         * and continue search (find "edit" later), else handle match. */
        // if (better-match-overlaps-right-ahead)
        //   continue;
        result.append(input, s, i).append(vals[p]);
        i += keys[p].length();
        s = i--;
      }
    }
  }
  if (s == lo) // no matches? no changes!
    return input;
  return result.append(input, s, hi).toString();
}
Robin479
fuente
1

Mira esto:

String.format(str,STR[])

Por ejemplo:

String.format( "Put your %s where your %s is", "money", "mouth" );
Ali
fuente
0

Resumen: implementación de clase única de la respuesta de Dave, para elegir automáticamente el más eficiente de los dos algoritmos.

Esta es una implementación completa de una sola clase basada en la excelente respuesta anterior de Dave Jarvis . La clase elige automáticamente entre los dos algoritmos suministrados diferentes, para una máxima eficiencia. (Esta respuesta es para personas a las que les gustaría copiar y pegar rápidamente).

Clase ReplaceStrings:

package somepackage

import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import org.ahocorasick.trie.Emit;
import org.ahocorasick.trie.Trie;
import org.ahocorasick.trie.Trie.TrieBuilder;
import org.apache.commons.lang3.StringUtils;

/**
 * ReplaceStrings, This class is used to replace multiple strings in a section of text, with high
 * time efficiency. The chosen algorithms were adapted from: https://stackoverflow.com/a/40836618
 */
public final class ReplaceStrings {

    /**
     * replace, This replaces multiple strings in a section of text, according to the supplied
     * search and replace definitions. For maximum efficiency, this will automatically choose
     * between two possible replacement algorithms.
     *
     * Performance note: If it is known in advance that the source text is long, then this method
     * signature has a very small additional performance advantage over the other method signature.
     * (Although either method signature will still choose the best algorithm.)
     */
    public static String replace(
        final String sourceText, final Map<String, String> searchReplaceDefinitions) {
        final boolean useLongAlgorithm
            = (sourceText.length() > 1000 || searchReplaceDefinitions.size() > 25);
        if (useLongAlgorithm) {
            // No parameter adaptations are needed for the long algorithm.
            return replaceUsing_AhoCorasickAlgorithm(sourceText, searchReplaceDefinitions);
        } else {
            // Create search and replace arrays, which are needed by the short algorithm.
            final ArrayList<String> searchList = new ArrayList<>();
            final ArrayList<String> replaceList = new ArrayList<>();
            final Set<Map.Entry<String, String>> allEntries = searchReplaceDefinitions.entrySet();
            for (Map.Entry<String, String> entry : allEntries) {
                searchList.add(entry.getKey());
                replaceList.add(entry.getValue());
            }
            return replaceUsing_StringUtilsAlgorithm(sourceText, searchList, replaceList);
        }
    }

    /**
     * replace, This replaces multiple strings in a section of text, according to the supplied
     * search strings and replacement strings. For maximum efficiency, this will automatically
     * choose between two possible replacement algorithms.
     *
     * Performance note: If it is known in advance that the source text is short, then this method
     * signature has a very small additional performance advantage over the other method signature.
     * (Although either method signature will still choose the best algorithm.)
     */
    public static String replace(final String sourceText,
        final ArrayList<String> searchList, final ArrayList<String> replacementList) {
        if (searchList.size() != replacementList.size()) {
            throw new RuntimeException("ReplaceStrings.replace(), "
                + "The search list and the replacement list must be the same size.");
        }
        final boolean useLongAlgorithm = (sourceText.length() > 1000 || searchList.size() > 25);
        if (useLongAlgorithm) {
            // Create a definitions map, which is needed by the long algorithm.
            HashMap<String, String> definitions = new HashMap<>();
            final int searchListLength = searchList.size();
            for (int index = 0; index < searchListLength; ++index) {
                definitions.put(searchList.get(index), replacementList.get(index));
            }
            return replaceUsing_AhoCorasickAlgorithm(sourceText, definitions);
        } else {
            // No parameter adaptations are needed for the short algorithm.
            return replaceUsing_StringUtilsAlgorithm(sourceText, searchList, replacementList);
        }
    }

    /**
     * replaceUsing_StringUtilsAlgorithm, This is a string replacement algorithm that is most
     * efficient for sourceText under 1000 characters, and less than 25 search strings.
     */
    private static String replaceUsing_StringUtilsAlgorithm(final String sourceText,
        final ArrayList<String> searchList, final ArrayList<String> replacementList) {
        final String[] searchArray = searchList.toArray(new String[]{});
        final String[] replacementArray = replacementList.toArray(new String[]{});
        return StringUtils.replaceEach(sourceText, searchArray, replacementArray);
    }

    /**
     * replaceUsing_AhoCorasickAlgorithm, This is a string replacement algorithm that is most
     * efficient for sourceText over 1000 characters, or large lists of search strings.
     */
    private static String replaceUsing_AhoCorasickAlgorithm(final String sourceText,
        final Map<String, String> searchReplaceDefinitions) {
        // Create a buffer sufficiently large that re-allocations are minimized.
        final StringBuilder sb = new StringBuilder(sourceText.length() << 1);
        final TrieBuilder builder = Trie.builder();
        builder.onlyWholeWords();
        builder.ignoreOverlaps();
        for (final String key : searchReplaceDefinitions.keySet()) {
            builder.addKeyword(key);
        }
        final Trie trie = builder.build();
        final Collection<Emit> emits = trie.parseText(sourceText);
        int prevIndex = 0;
        for (final Emit emit : emits) {
            final int matchIndex = emit.getStart();

            sb.append(sourceText.substring(prevIndex, matchIndex));
            sb.append(searchReplaceDefinitions.get(emit.getKeyword()));
            prevIndex = emit.getEnd() + 1;
        }
        // Add the remainder of the string (contains no more matches).
        sb.append(sourceText.substring(prevIndex));
        return sb.toString();
    }

    /**
     * main, This contains some test and example code.
     */
    public static void main(String[] args) {
        String shortSource = "The quick brown fox jumped over something. ";
        StringBuilder longSourceBuilder = new StringBuilder();
        for (int i = 0; i < 50; ++i) {
            longSourceBuilder.append(shortSource);
        }
        String longSource = longSourceBuilder.toString();
        HashMap<String, String> searchReplaceMap = new HashMap<>();
        ArrayList<String> searchList = new ArrayList<>();
        ArrayList<String> replaceList = new ArrayList<>();
        searchReplaceMap.put("fox", "grasshopper");
        searchReplaceMap.put("something", "the mountain");
        searchList.add("fox");
        replaceList.add("grasshopper");
        searchList.add("something");
        replaceList.add("the mountain");
        String shortResultUsingArrays = replace(shortSource, searchList, replaceList);
        String shortResultUsingMap = replace(shortSource, searchReplaceMap);
        String longResultUsingArrays = replace(longSource, searchList, replaceList);
        String longResultUsingMap = replace(longSource, searchReplaceMap);
        System.out.println(shortResultUsingArrays);
        System.out.println("----------------------------------------------");
        System.out.println(shortResultUsingMap);
        System.out.println("----------------------------------------------");
        System.out.println(longResultUsingArrays);
        System.out.println("----------------------------------------------");
        System.out.println(longResultUsingMap);
        System.out.println("----------------------------------------------");
    }
}

Dependencias de Maven necesarias:

(Agregue estos a su archivo pom si es necesario).

    <!-- Apache Commons utilities. Super commonly used utilities.
    https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 -->
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-lang3</artifactId>
        <version>3.10</version>
    </dependency>

    <!-- ahocorasick, An algorithm used for efficient searching and 
    replacing of multiple strings.
    https://mvnrepository.com/artifact/org.ahocorasick/ahocorasick -->
    <dependency>
        <groupId>org.ahocorasick</groupId>
        <artifactId>ahocorasick</artifactId>
        <version>0.4.0</version>
    </dependency>
BlakeTNC
fuente