Java Substituindo várias substring diferentes em uma string de uma vez (ou da maneira mais eficiente)


97

Preciso substituir muitas substring diferentes em uma string da maneira mais eficiente. Existe outra maneira diferente da forma de força bruta de substituir cada campo usando string.replace?

Respostas:


102

Se a string em que você está operando for muito longa, ou se estiver operando em muitas strings, pode valer a pena usar um java.util.regex.Matcher (isso requer tempo inicial para compilar, então não será eficiente se a sua entrada for muito pequena ou se o padrão de pesquisa mudar com frequência).

Abaixo está um exemplo completo, baseado em uma lista de tokens tirada de um mapa. (Usa StringUtils do 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());

Uma vez que a expressão regular é compilada, verificar a string de entrada é geralmente muito rápido (embora se sua expressão regular for complexa ou envolver retrocesso, você ainda precisará fazer um benchmark para confirmar isso!)


1
Sim, precisa ser avaliado quanto ao número de iterações.
techzen

5
Eu acho que você deve escapar de caracteres especiais em cada token antes de fazer"%(" + StringUtils.join(tokens.keySet(), "|") + ")%";
Desenvolvedor Marius Žil 19nas

Observe que pode-se usar StringBuilder para um pouco mais de velocidade. StringBuilder não está sincronizado. editar gritos só funciona com java 9
Tinus Tate

3
Leitor futuro: para regex, "(" e ")" incluirá o grupo a ser pesquisado. O "%" conta como literal no texto. Se seus termos não começam E terminam com "%", eles não serão encontrados. Portanto, ajuste os prefixos e sufixos em ambas as partes (texto + código).
linuxunil

66

Algoritmo

Uma das maneiras mais eficientes de substituir strings correspondentes (sem expressões regulares) é usar o algoritmo Aho-Corasick com um Trie de desempenho (pronuncia-se "tentativa"), algoritmo de hash rápido e implementação de coleções eficiente .

Código Simples

Uma solução simples aproveita o Apache da StringUtils.replaceEachseguinte maneira:

  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 );
  }

Isso fica mais lento em textos grandes.

Código Rápido

A implementação de Bor do algoritmo Aho-Corasick introduz um pouco mais de complexidade que se torna um detalhe de implementação usando uma fachada com a mesma assinatura 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 os benchmarks, o buffer foi criado usando randomNumeric da seguinte forma:

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

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

Onde MATCHES_DIVISORdita o número de variáveis ​​a serem injetadas:

  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 ) );
    }
  }

O próprio código de referência ( JMH parecia exagero):

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

1.000.000: 1.000

Um micro-benchmark simples com 1.000.000 de caracteres e 1.000 strings colocadas aleatoriamente para substituir.

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

Sem resposta.

10.000: 1.000

Usando 10.000 caracteres e 1.000 strings correspondentes para substituir:

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

A divisão se fecha.

1.000: 10

Usando 1.000 caracteres e 10 strings correspondentes para substituir:

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

Para cordas curtas, a sobrecarga de configurar Aho-Corasick eclipsa a abordagem de força bruta por StringUtils.replaceEach.

Uma abordagem híbrida com base no comprimento do texto é possível, para obter o melhor de ambas as implementações.

Implementações

Considere comparar outras implementações para texto com mais de 1 MB, incluindo:

Papéis

Artigos e informações relacionadas ao algoritmo:


5
Parabéns por atualizar esta questão com novas informações valiosas, isso é muito bom. Acho que um benchmark JMH ainda é apropriado, pelo menos para valores razoáveis ​​como 10.000: 1.000 e 1.000: 10 (o JIT pode fazer otimizações mágicas às vezes).
Tunaki

remova o builder.onlyWholeWords () e ele funcionará de forma semelhante à substituição da string.
Ondrej Sotolar de

Muito obrigado por esta excelente resposta. Isso é definitivamente muito útil! Só queria comentar que, para comparar as duas abordagens e também para dar um exemplo mais significativo, deve-se construir o Trie apenas uma vez na segunda abordagem e aplicá-lo a muitas strings de entrada diferentes. Acho que essa é a principal vantagem de ter acesso ao Trie versus StringUtils: você só o constrói uma vez. Mesmo assim, muito obrigado por esta resposta. Ele compartilha muito bem a metodologia para implementar a segunda abordagem
Vic Seedoubleyew

Um excelente ponto, @VicSeedoubleyew. Quer atualizar a resposta?
Dave Jarvis

9

Isso funcionou para mim:

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

Exemplo:

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

Resultado: maçã-banana-frui-


Exatamente o que eu precisava, meu amigo :)
GOXR3PLUS

7

Se você for alterar uma String muitas vezes, geralmente é mais eficiente usar um StringBuilder (mas meça seu desempenho para descobrir) :

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 você substitui uma String, um novo objeto String é criado, porque as Strings são imutáveis. StringBuilder é mutável, ou seja, pode ser alterado o quanto você quiser.


Estou com medo, não ajuda. Sempre que a substituição for diferente do comprimento original, você precisará de alguma mudança, o que pode ser mais caro do que construir a corda novamente. Ou eu estou esquecendo de alguma coisa?
maaartinus

4

StringBuilderexecutará a substituição com mais eficiência, já que seu buffer de matriz de caracteres pode ser especificado para um comprimento necessário. StringBuilderfoi projetado para mais do que apenas anexar!

Claro que a verdadeira questão é se esta é uma otimização longe demais? A JVM é muito boa em lidar com a criação de vários objetos e a coleta de lixo subsequente e, como todas as questões de otimização, minha primeira pergunta é se você mediu isso e determinou que é um problema.


2

Que tal usar o método replaceAll () ?


4
Muitas substrings diferentes podem ser manipuladas em um regex (/substring1|substring2|.../). Tudo depende do tipo de substituição que o OP está tentando fazer.
Avi

4
O OP está procurando por algo mais eficiente do questr.replaceAll(search1, replace1).replaceAll(search2, replace2).replaceAll(search3, replace3).replaceAll(search4, replace4)
Kip

2

Rythm, um mecanismo de modelo java agora lançado com um novo recurso chamado modo de interpolação de String que permite fazer algo como:

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

O caso acima mostra que você pode passar argumentos para o modelo por posição. Rythm também permite que você passe argumentos por nome:

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

Nota Rythm é MUITO RÁPIDO, cerca de 2 a 3 vezes mais rápido que String.format e velocity, porque compila o modelo em código de bytes java, o desempenho do tempo de execução é muito próximo da concatentação com StringBuilder.

Links:


Este é um recurso muito antigo disponível com várias linguagens de modelagem como velocidade, JSP até. Também não responde à pergunta que não requer que as strings de pesquisa estejam em qualquer formato predefinido.
Angsuman Chakraborty de

Interessante, a resposta aceita fornece um exemplo: "%cat% really needs some %beverage%."; esse %token separado não é um formato predefinido? Seu primeiro ponto é ainda mais engraçado, o JDK oferece muitos "recursos antigos", alguns deles começam na década de 90, por que as pessoas se incomodam em usá-los? Seus comentários e votos negativos não fazem nenhum sentido
Gelin Luo

Qual é o ponto de introduzir o mecanismo de modelo Rythm quando já existem muitos motores de modelo pré-existentes e amplamente usados ​​como Velocity ou Freemarker para inicializar? Também por que apresentar outro produto quando as funcionalidades básicas do Java são mais do que suficientes. Eu realmente duvido da sua declaração sobre desempenho porque o padrão também pode ser compilado. Adoraria ver alguns números reais.
Angsuman Chakraborty

Verde, você está perdendo o ponto. O questionador deseja substituir strings arbitrárias, enquanto sua solução substituirá apenas strings em formato predefinido como @ precedido. Sim, o exemplo usa%, mas apenas como exemplo, não como um fator limitante. Então você responde não responde à pergunta e, portanto, ao ponto negativo.
Angsuman Chakraborty

2

O que segue é baseado na resposta de Todd Owen . Essa solução tem o problema de que, se as substituições contiverem caracteres com significado especial em expressões regulares, você poderá obter resultados inesperados. Eu também queria poder opcionalmente fazer uma pesquisa que não diferencia maiúsculas de minúsculas. Aqui está o que eu descobri:

/**
 * 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();
}

Aqui estão meus casos de teste de unidade:

@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;
}

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();
}

1

Verifique isto:

String.format(str,STR[])

Por exemplo:

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

0

Resumo: Implementação de classe única da resposta de Dave, para escolher automaticamente o mais eficiente dos dois algoritmos.

Esta é uma implementação completa de classe única baseada na excelente resposta acima de Dave Jarvis . A classe escolhe automaticamente entre os dois algoritmos diferentes fornecidos, para máxima eficiência. (Esta resposta é para pessoas que desejam apenas copiar e colar rapidamente.)

Classe 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("----------------------------------------------");
    }
}

Dependências necessárias do Maven:

(Adicione-os ao seu arquivo pom, se necessário.)

    <!-- 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>
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.