Frecuencia de palabras con el api Stream

Un simple ejemplo donde se usa el api Stream de java, para extraer las palabras y contar el numero de repeticiones, para ello usamos una función para eliminar los caracteres en este caso serán, [.,!] y caracter de espaciado uno solo así " " más los Strings que sean empty así "", que debemos filtrar si o si.

private String normalize(final String word) {
    return word.toLowerCase().replaceAll("[\\.\\,\\!]", ""); (1)
}
1 La expresión regular que se me ocurrió es esta [\\.\\,\\!] usando doble backslash, todas los matches de puntos, comas, exclamación, también las convertiremos en minúsculas, con toLowerCase junto con el método replaceAll
Nuestro texto objetivo es el siguiente:

Hola que tal, bienvenidos a BettaTech, Si os está gustando este video, suscribiros y darle a la campanita para ver los nuevos videos que vaya subiendo!

final String[] words = "Hola que tal,
" bienvenidos a BettaTech, Si os está gustando " +
"este vídeo, suscribiros y darle a la campanita" +
" para ver los nuevos videos que vaya subiendo!".split(" "); (1)
1 Este String lo introducimos en un array de String e invocamos al método split, con un caracter en blanco como parámetro, para que después de cada espacio almacene cada palabra en una posición de nuestro array.
System.out.printf("%-15s%s%n","Word","Frecuency"); (1)
1 Imprimimos con printf un formateo de 15 caracteres de espaciado
.filter(this::filterEmptyAndNull) (1)
1 Invocamos al método filter del Stream, la fase de filtrado para los carácteres empty y null de nuestro String, para compactar más el código, pasaremos el propio método por referencia method reference ::
private boolean filterEmptyAndNull(final String word) {
    return Objects.nonNull(word) && !"".equals(word);
}
.map(this::normalize) (1)
1 La función map, que es la fase de mapping del stream, en este caso hacemos un method::reference ya que el método normalize es una función con un parámetro y retorna un objeto, encajando en nuestro map perfectamente.
.collect(Collectors.groupingBy(e -> e,Collectors.counting())) (1)
1 Hacemos una reducción mutable a este Stream con collect, donde el método groupingBy le dice al collect que debe agrupar todos los elementos en un Map, el primer parámetro es que retorne el mismo valor pasado en la expresión lambda siendo una operación muy común podríamos usar tanto e -> e como Function.identity(), el segundo parámetro es un downstream collector `Collectors.counting()`que nos permitirá contar los elementos repetidos, permitiendo crear el value de cada key.
package com.test.frencuencywords.service;

import lombok.NonNull;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
 * @author rubn
 */
@Service
public class FrecuencyWordsService {

    private static final String REG_EXP = "[\\.\\,\\!]";
    private static final String SPLIT_ESPACE_CARACTER = " ";

    /**
     *
     * Process with parallelStream()
     *
     * @param words to count
     * @return Map<String,Long> with key(word), and value(frecuency)
     */
    public Map<String,Long> frecuencyWordsParallel(@NonNull final String words) {
        final String[] splitWords = words.split(SPLIT_ESPACE_CARACTER);
        return Arrays.stream(splitWords)
                .parallel() (1)
                .filter(this::filterEmptyAndNull)
                .map(this::normalize)
                .collect(Collectors.groupingBy(Function.identity(),Collectors.counting()));
    }

    /**
     *
     * Process with stream()
     *
     * @param words to count
     * @return Map<String,Long> with key(word), and value(frecuency)
     */
    public Map<String,Long> frecuencyWords(@NonNull final String words) {
        final String[] splitWords = words.split(SPLIT_ESPACE_CARACTER);
        return Arrays.stream(splitWords)
                .filter(this::filterEmptyAndNull)
                .map(this::normalize)
                .collect(Collectors.groupingBy(Function.identity(),Collectors.counting()));
    }

    /**
     *
     * @param input path
     * @return Map<String,Long> with key(word), and value(frecuency)
     */
    public Map<String,Long> frecuencyWordsFromFile(final Path input) { (2)
        try (final Stream<String> lines = Files.lines(input)) { (3)

            return Arrays.stream(lines.collect(Collectors.joining()) (4)
                    .split(SPLIT_ESPACE_CARACTER))
                    .filter(this::filterEmptyAndNull)
                    .map(this::normalize)
                    .collect(Collectors.groupingBy(Function.identity(),Collectors.counting()));

        } catch (IOException ex) {
            throw new RuntimeException(ex);
        }
    }

    private boolean filterEmptyAndNull(final String word) {
        return Objects.nonNull(word) && !"".equals(word);
    }

    private String normalize(final String word) {
        return word.toLowerCase().replaceAll(REG_EXP, "");
    }

}
1 Usando el método parallel, muchas veces esto no dan un incremento en el performance notable, rara vez se usa.
2 Usamos el parametro path que contendra la ruta del input o fichero en este caso, que queremos leer.
3 Usamos el metodo lines, para leer el path y obtener un Streams de Strings, algo especial es que no carga todo el contenido en memoria 🥰.
4 Recolectamos todo el Stream de Strings en un String.

Test final y también con lectura de archivo que contiene el mismo texto target

package com.test.frencuencywords;

import com.test.frencuencywords.service.FrecuencyWordsService;
import com.test.frencuencywords.util.Memory;
import lombok.extern.log4j.Log4j2;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.stream.Collectors;

import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat;

/**
 * @author rubn
 *
 * Simple test for frecuency words
 */
@Log4j2
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = {FrecuencyWordsService.class,Memory.class})
@DisplayName("Frecuency words, reading text, sync, async from String or File")
class FrecuencyTest {

    private static final String FORMAT_PRINTF_15 = "%-15s%s";
    private static final String WORD = "Word";
    private static final String HOLA = "hola";
    private static final String FRECUENCY = "Frecuency";

    private static final Path PATH_TEXT_FILE = Path.of("src/test/resources/textoSimple.txt");

    @Autowired
    private FrecuencyWordsService frecuencyWordsService;

    @Autowired
    private Memory memory;

    private final String WORDS = "Hola que tal, bienvenidos a BettaTech, Si os está gustando este vídeo, suscribiros y darle a la campanita para ver los nuevos videos que vaya subiendo!";

    @Test
    @DisplayName("reading text from String text with blockingIO")
    void testFrecuencyWordsNull() {
        log.info("Run Frecuency");
        log.info(String.format(FORMAT_PRINTF_15,WORD,FRECUENCY));
        final Map<String,Long> mapWordsSync = frecuencyWordsService.frecuencyWords(WORDS);
        Assertions.assertNotNull(mapWordsSync);

        mapWordsSync.forEach((word, frecuency) -> { (1)
            log.info(String.format(FORMAT_PRINTF_15, word, frecuency));
        });

        final long actual = mapWordsSync.get(HOLA);

        final long expected = 1;
        assertThat(actual).isEqualTo(expected);
        /*
         * total memory consumption
         */
        log.info(memory.getTotalMemory());
    }

    @Test
    @DisplayName("reading text from String text parallel")
    void testFrecuencyWordsParallel() {
        log.info("Run Frecuency Parallel");
        log.info(String.format(FORMAT_PRINTF_15,WORD,FRECUENCY));
        final Map<String,Long> mapWordsParallel = frecuencyWordsService.frecuencyWordsParallel(WORDS);
        Assertions.assertNotNull(mapWordsParallel);

        mapWordsParallel.forEach((word, frecuency) -> {
            log.info(String.format(FORMAT_PRINTF_15, word, frecuency));
        });

        final long actual = mapWordsParallel.get(HOLA);

        final long expected = 1;
        assertThat(actual).isEqualTo(expected);
        /*
         * total memory consumption
         */
        log.info(memory.getTotalMemory());
    }

    @Test
    @DisplayName("reading text from input with 'BufferedReader' ")
    void testFrecuencyWordsFromTextFile() {
        if(Files.exists(PATH_TEXT_FILE)) {
            log.info("Run Frecuency Parallel");
            try (final InputStream inputStream = this.getClass().getResourceAsStream("/textoSimple.txt");
                 final Reader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8);
                 final BufferedReader br = new BufferedReader(reader)) {

                final StringBuilder sb = new StringBuilder();
                //read lines with Stream api
                sb.append(br.lines().collect(Collectors.joining()));

                final Map<String, Long> mapWordsParallel = frecuencyWordsService.frecuencyWords(sb.toString());
                log.info(String.format(FORMAT_PRINTF_15,WORD,FRECUENCY));
                mapWordsParallel.forEach((word, frecuency) -> {
                    log.info(String.format(FORMAT_PRINTF_15, word, frecuency));
                });

                final long result = mapWordsParallel.get(HOLA);
                final long expected = 1;

                assertThat(result).isEqualTo(expected);

            } catch (IOException ex) {
                log.error(ex.getMessage());
            }
            /*
             * total memory consumption
             */
            log.info(memory.getTotalMemory());
        } else {
            throw new RuntimeException("File not found!");
        }

    }

    @Test
    @DisplayName("reading text from input with 'Files.lines' ")
    void frecuencyFromPath() {
        log.info("Run Frecuency Parallel");
        final Map<String,Long> mapWordsParallel = frecuencyWordsService.frecuencyWordsFromFile(PATH_TEXT_FILE);
        log.info(String.format(FORMAT_PRINTF_15, WORD,FRECUENCY));
        mapWordsParallel.forEach((word, frecuency) -> {
            log.info(String.format(FORMAT_PRINTF_15, word, frecuency));
        });

        final long result = mapWordsParallel.get(HOLA);
        final long expected = 1;

        assertThat(result).isEqualTo(expected);
        /*
         * total memory consumption
         */
        log.info(memory.getTotalMemory());
    }

}
1 Recorriendo el stream con un forEach, pero como ya nuestro collect nos retorna un Map<String,Long> el forEach de la interface Map, tiene un BiConsumer como parámetro, y esta a su vez, el método accept, que tiene 2 parámetros genéricos, uno para la key y otro para el value, forEach((key,value) -> {})

Salida en consola con el método usando streams parallel

15:35:14.030 [main] INFO com.test.frencuencywords.FrecuencyTest - Run Frecuency Parallel
15:35:14.035 [main] INFO com.test.frencuencywords.FrecuencyTest - Word           Frecuency
15:35:14.035 [main] INFO com.test.frencuencywords.FrecuencyTest - darle          1
15:35:14.036 [main] INFO com.test.frencuencywords.FrecuencyTest - que            2
15:35:14.036 [main] INFO com.test.frencuencywords.FrecuencyTest - a              2
15:35:14.036 [main] INFO com.test.frencuencywords.FrecuencyTest - vaya           1
15:35:14.036 [main] INFO com.test.frencuencywords.FrecuencyTest - os             1
15:35:14.036 [main] INFO com.test.frencuencywords.FrecuencyTest - nuevos         1
15:35:14.036 [main] INFO com.test.frencuencywords.FrecuencyTest - videos         1
15:35:14.036 [main] INFO com.test.frencuencywords.FrecuencyTest - bienvenidos    1
15:35:14.036 [main] INFO com.test.frencuencywords.FrecuencyTest - hola           1
15:35:14.036 [main] INFO com.test.frencuencywords.FrecuencyTest - verlos         1
15:35:14.036 [main] INFO com.test.frencuencywords.FrecuencyTest - suscribiros    1
15:35:14.036 [main] INFO com.test.frencuencywords.FrecuencyTest - subiendo       1
15:35:14.036 [main] INFO com.test.frencuencywords.FrecuencyTest - bettatech      1
15:35:14.036 [main] INFO com.test.frencuencywords.FrecuencyTest - este           1
15:35:14.036 [main] INFO com.test.frencuencywords.FrecuencyTest - campanita      1
15:35:14.036 [main] INFO com.test.frencuencywords.FrecuencyTest - para           1
15:35:14.037 [main] INFO com.test.frencuencywords.FrecuencyTest - está           1
15:35:14.037 [main] INFO com.test.frencuencywords.FrecuencyTest - la             1
15:35:14.037 [main] INFO com.test.frencuencywords.FrecuencyTest - si             1
15:35:14.037 [main] INFO com.test.frencuencywords.FrecuencyTest - gustando       1
15:35:14.037 [main] INFO com.test.frencuencywords.FrecuencyTest - y              1
15:35:14.037 [main] INFO com.test.frencuencywords.FrecuencyTest - vídeo          1
15:35:14.037 [main] INFO com.test.frencuencywords.FrecuencyTest - tal            1
15:35:14.076 [main] INFO com.test.frencuencywords.FrecuencyTest - Total Memory 57MB (1)
1 Consumo total de memoria en ese momento, suele variar mucho dependiendo el hardware.

frecuencyWordsTest

Inpiración

Según esta pregunta se la hicieron a un doc para un entrevista, donde resuelve este caso con TypeScript, nada fácil para resolver así a primeras, porque siempre hay soluciones más eficientes que otras, y este caso no es la excepción, porque mi ejemplo tengo 2 maneras más de hacerlo con switch y for, en donde ambas soluciones incremento posiciones.