Una vez más, vuelvo con otro vistazo a algunas formas de resolver un problema común de Python. Esta vez, veremos cómo dividir una cadena por espacios en blanco (y otros separadores) en Python.

Si tiene prisa, aquí está la conclusión clave. Podría escribir su propia función de división de espacios en blanco, pero a menudo son lentas y carecen de solidez. En su lugar, probablemente debería optar por la split()función incorporada de Python Funciona para cualquier cadena de la siguiente manera: "What a Wonderful World".split()Si se hace correctamente, obtendrá una buena lista de subcadenas sin todos esos espacios en blanco (por ejemplo ["What", "a", "Wonderful", "World"]).

En el resto de este artículo, veremos la solución descrita anteriormente con más detalle. Además, intentaremos escribir nuestra propia solución. Luego, los compararemos todos por rendimiento. Al final, te pediré que afrontes un pequeño desafío.

¡Empecemos!

Descripción del problema

Cuando hablamos de dividir una cuerda, de lo que realmente estamos hablando es del proceso de dividir una cuerda en partes. Resulta que hay muchas formas de dividir una cuerda. Para los propósitos de este artículo, solo veremos cómo dividir una cadena por espacios en blanco.

Por supuesto, ¿qué significa dividir una cadena por espacios en blanco? Bueno, veamos un ejemplo:

1
"How are you?"

Aquí, los únicos dos caracteres de espacio en blanco son los dos espacios. Como resultado, dividir esta cadena por espacios en blanco daría como resultado una lista de tres cadenas:

1
["How", "are", "you?"]

Por supuesto, hay muchos tipos diferentes de caracteres de espacios en blanco. Desafortunadamente, los caracteres que se consideran espacios en blanco dependen totalmente del conjunto de caracteres que se utilice. Como resultado, simplificaremos este problema preocupándonos solo de los caracteres Unicode (a partir de la fecha de publicación).

En el juego de caracteres Unicode, hay 17 caracteres de "separador, espacio" . Además, hay otros 8 caracteres de espacio en blanco que incluyen elementos como separadores de línea. Como resultado, la siguiente cadena es un poco más interesante:

1
"Hi, Ben!\nHow are you?"

Con la adición del salto de línea, esperaríamos que dividir por espacios en blanco resulte en la siguiente lista:

1
["Hi,", "Ben!", "How", "are", "you?"]

En este artículo, veremos algunas formas de escribir código que dividirá una cadena por espacios en blanco y almacenará el resultado en una lista.

Soluciones

Como siempre, hay muchas formas diferentes de dividir una cadena por espacios en blanco. Para empezar, intentaremos escribir nuestra propia solución. Luego, veremos algunas soluciones prácticas más.

Dividir una cadena por espacio en blanco usando fuerza bruta

Si me dieran la descripción del problema anterior y me pidieran que lo resolviera sin usar ninguna biblioteca, esto es lo que haría:

01
02
03
04
05
06
07
08
09
10
11
items = []
my_string = "Hi, how are you?"
whitespace_chars = [" ", ..., "\n"]
start_index = 0
end_index = 0
for character in my_string:
  if character in whitespace_chars:
    items.append(my_string[start_index: end_index])
    start_index = end_index + 1
  items.append(my_string[start_index: end_index])
  end_index += 1

Aquí, decidí construir algunas variables. Primero, necesitamos rastrear el resultado final que es itemsen este caso. Entonces, necesitamos algún tipo de cadena para trabajar (por ejemplo my_string).

Para realizar la división, necesitaremos rastrear un par de índices: uno para el frente de cada subcadena (por ejemplo start_index) y otro para la parte posterior de la subcadena (por ejemplo end_index).

Además de todo eso, necesitamos alguna forma de verificar que un carácter es de hecho un espacio en blanco. Para hacer eso, creamos una lista de caracteres de espacios en blanco llamados whitespace_charsEn lugar de enumerar todos los caracteres de espacio en blanco, hice trampa y mostré dos ejemplos con algunas elipses. Asegúrese de eliminar los puntos suspensivos antes de ejecutar este código . Por alguna razón, Python le da significado a esos tres puntos , por lo que en realidad no generará errores (aunque es probable que tampoco cause ningún daño).

Usando estas variables, podemos recorrer nuestra cadena y construir nuestras subcadenas. Lo hacemos comprobando si cada carácter es un espacio en blanco. Si es así, sabemos que necesitamos construir una subcadena y actualizar start_indexpara comenzar a rastrear la siguiente palabra. Luego, cuando hayamos terminado, podemos tomar la última palabra y almacenarla.

Ahora, hay mucho desorden aquí. Para hacer la vida un poco más fácil, decidí mover el código a una función que podríamos modificar a medida que avanzamos:

01
02
03
04
05
06
07
08
09
10
11
12
def split_string(my_string: str):
  items = []
  whitespace_chars = [" ", ..., "\n"]
  start_index = 0
  end_index = 0
  for character in my_string:
    if character in whitespace_chars:
      items.append(my_string[start_index: end_index])
      start_index = end_index + 1
    end_index += 1
  items.append(my_string[start_index: end_index])
  return items

Ahora bien, esta solución es extremadamente propensa a errores . Para demostrarlo, intente ejecutar esta función de la siguiente manera:

1
split_string("Hello  World")  # returns ['Hello', '', 'World']

¿Observa cómo tener dos espacios seguidos hace que almacenemos cadenas vacías? Sí, eso no es ideal. En la siguiente sección, veremos una forma de mejorar este código.

Dividir una cadena por espacio en blanco usando el estado

Ahora, tomé prestada esta solución de un método que les pedimos a los estudiantes que escriban para un laboratorio en uno de los cursos que enseño. Básicamente, el método se llama " nextWordOrSeparator ", que es un método que se ve así:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
dieciséis
17
18
19
20
21
/**
  * Returns the first "word" (maximal length string of characters not in
  * {@code separators}) or "separator string" (maximal length string of
  * characters in {@code separators}) in the given {@code text} starting at
  * the given {@code position}.
  */
private static String nextWordOrSeparator(String text, int position,
            Set<Character> separators) {
        assert text != null : "Violation of: text is not null";
        assert separators != null : "Violation of: separators is not null";
        assert 0 <= position : "Violation of: 0 <= position";
        assert position < text.length() : "Violation of: position < |text|";
 
        // TODO - fill in body
 
        /*
         * This line added just to make the program compilable. Should be
         * replaced with appropriate return statement.
         */
        return "";
}

Una forma de implementar este método es comprobar si el primer carácter es un separador o no. Si es así, repita hasta que no lo esté. Si no es así, repita hasta que lo esté.

Normalmente, esto se hace escribiendo dos bucles separados. Un bucle comprueba continuamente los caracteres hasta que hay un carácter en el conjunto de separadores. Mientras tanto, el otro bucle hace lo contrario.

Por supuesto, creo que eso es un poco redundante, así que escribí mi solución usando un solo ciclo (esta vez en Python):

1
2
3
4
5
6
def next_word_or_separator(text: str, position: int, separators: list):
  end_index = position
  is_separator = text[position] in separators
  while end_index < len(text) and is_separator == (text[end_index] in separators):
    end_index += 1
  return text[position: end_index]

Aquí, rastreamos un par de variables. Primero, necesitamos un end_index, para saber dónde dividir nuestra cadena. Además, necesitamos determinar si estamos tratando con una palabra o un separador. Para ello, se comprueba si el carácter en la corriente positionen textestá en separatorsLuego, almacenamos el resultado en is_separator.

Con is_separator, todo lo que queda por hacer es recorrer la cadena hasta que encontremos un carácter diferente. Para hacer eso, ejecutamos repetidamente el mismo cálculo para el que corrimos is_separatorPara hacerlo más obvio, he almacenado esa expresión en una función lambda:

1
2
3
4
5
6
7
def next_word_or_separator(text: str, position: int, separators: list):
  test_separator = lambda x: text[x] in separators
  end_index = position
  is_separator = test_separator(position)
  while end_index < len(text) and is_separator == test_separator(end_index):
    end_index += 1
  return text[position: end_index]

En cualquier caso, este ciclo se ejecutará hasta que nos quedemos sin cadena o hasta que nuestra test_separatorfunción nos dé un valor diferente de is_separatorPor ejemplo, si is_separatores, Trueentonces no romperemos hasta que test_separatorsea False.

Ahora, podemos usar esta función para hacer nuestra primera solución un poco más robusta:

1
2
3
4
5
6
7
8
9
def split_string(my_string: str):
  items = []
  whitespace_chars = [" ", ..., "\n"]
  i = 0
  while i < len(my_string):
    sub = next_word_or_separator(my_string, i, whitespace_chars)
    items.append(sub)
    i += len(sub)
  return items

Desafortunadamente, este código sigue siendo incorrecto porque no nos molestamos en comprobar si lo que se devuelve es una palabra o un separador. Para hacer eso, necesitaremos ejecutar una prueba rápida:

01
02
03
04
05
06
07
08
09
10
def split_string(my_string: str):
  items = []
  whitespace_chars = [" ", ..., "\n"]
  i = 0
  while i < len(my_string):
    sub = next_word_or_separator(my_string, i, whitespace_chars)
    if sub[0] not in whitespace_chars:
      items.append(sub)
    i += len(sub)
  return items

¡Ahora tenemos una solución que es un poco más robusta! Además, hace el trabajo para cualquier cosa que consideremos separadores; ni siquiera tienen que ser espacios en blanco. Sigamos adelante y adaptemos esta última vez para permitir que el usuario ingrese los separadores que desee:

1
2
3
4
5
6
7
8
9
def split_string(my_string: str, seps: list):
  items = []
  i = 0
  while i < len(my_string):
    sub = next_word_or_separator(my_string, i, seps)
    if sub[0] not in seps:
      items.append(sub)
    i += len(sub)
  return items

Luego, cuando ejecutemos esto, veremos que podemos dividir por lo que queramos:

01
02
03
04
05
06
07
08
09
10
>>> split_string("Hello,    World", [" "])
['Hello,', 'World']
>>> split_string("Hello,    World", ["l"])
['He', 'o,    Wor', 'd']
>>> split_string("Hello,    World", ["l", "o"])
['He', ',    W', 'r', 'd']
>>> split_string("Hello,    World", ["l", "o", " "])
['He', ',', 'W', 'r', 'd']
>>> split_string("Hello,    World", [",", " "])
['Hello', 'World']

¡¿Cuan genial es eso?! En la siguiente sección, veremos algunas herramientas integradas que hacen exactamente esto.

Dividir una cadena por espacio en blanco usando split()

Mientras pasamos todo este tiempo tratando de escribir nuestro propio método de división, Python tenía uno integrado todo el tiempo. Se llama split()y podemos llamarlo directamente en cadenas:

1
2
my_string = "Hello, World!"
my_string.split()  # returns ["Hello,", "World!"]

Además, podemos proporcionar nuestros propios separadores para dividir la cadena:

1
2
my_string = "Hello, World!"
my_string.split(",")  # returns ['Hello', ' World!']

Sin embargo, este método no funciona como el método que proporcionamos. Si ingresamos varios separadores, el método solo coincidirá con la cadena combinada:

1
2
my_string = "Hello, World!"
my_string.split("el")  # returns ['H', 'lo, World!']

En la documentación , esto se describe como un "algoritmo diferente" del comportamiento predeterminado. En otras palabras, el algoritmo de espacios en blanco tratará los caracteres de espacios en blanco consecutivos como una sola entidad. Mientras tanto, si se proporciona un separador, el método se divide en cada aparición de ese separador:

1
2
my_string = "Hello, World!"
my_string.split("l")  # returns ['He', '', 'o, Wor', 'd!']

¡Pero eso no es todo! Este método también puede limitar el número de divisiones mediante un parámetro adicional maxsplit:

1
2
my_string = "Hello, World! Nice to meet you."
my_string.split(maxsplit=2)  # returns ['Hello,', 'World!', 'Nice to meet you.']

¿Cuan genial es eso? En la siguiente sección, veremos cómo esta solución se compara con las soluciones que escribimos nosotros mismos.

Actuación

Para probar el rendimiento, usaremos la timeitbiblioteca. Básicamente, nos permite calcular el tiempo de ejecución de nuestros fragmentos de código para compararlos. Si desea obtener más información sobre este proceso, documenté mi enfoque en un artículo sobre pruebas de rendimiento en Python .

De lo contrario, sigamos adelante y convierta nuestras soluciones en cadenas:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
dieciséis
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
setup = """
zero_spaces = 'Jeremy'
one_space = 'Hello, World!'
many_spaces = 'I need to get many times stronger than everyone else!'
first_space = '    Well, what do we have here?'
last_space = 'Is this the Krusty Krab?    '
long_string = 'Spread love everywhere you go: first of all in your own house. Give love to your children, to your wife or husband, to a next door neighbor. Let no one ever come to you without leaving better and happier. Be the living expression of God’s kindness; kindness in your face, kindness in your eyes, kindness in your smile, kindness in your warm greeting.'
 
def split_string_bug(my_string: str):
  items = []
  whitespace_chars = [' ']
  start_index = 0
  end_index = 0
  for character in my_string:
    if character in whitespace_chars:
      items.append(my_string[start_index: end_index])
      start_index = end_index + 1
    end_index += 1
  items.append(my_string[start_index: end_index])
  return items
 
def next_word_or_separator(text: str, position: int, separators: list):
  test_separator = lambda x: text[x] in separators
  end_index = position
  is_separator = test_separator(position)
  while end_index < len(text) and is_separator == test_separator(end_index):
    end_index += 1
  return text[position: end_index]
 
def split_string(my_string: str, seps: list):
  items = []
  i = 0
  while i < len(my_string):
    sub = next_word_or_separator(my_string, i, seps)
    if sub[0] not in seps:
      items.append(sub)
    i += len(sub)
  return items
"""
 
split_string_bug = """
split_string_bug(zero_spaces)
"""
 
split_string = """
split_string(zero_spaces, [" "])
"""
 
split_python = """
zero_spaces.split()
"""

Para este primer conjunto de pruebas, decidí comenzar con una cadena que no tiene espacios:

1
2
3
4
5
6
7
>>> import timeit
>>> min(timeit.repeat(setup=setup, stmt=split_string_bug))
0.7218914000000041
>>> min(timeit.repeat(setup=setup, stmt=split_string))
2.867278899999974
>>> min(timeit.repeat(setup=setup, stmt=split_python))
0.0969244999998864

Parece que nuestra next_word_or_separator()solución es muy lenta. Mientras tanto, el incorporado split()es extremadamente rápido. Veamos si esa tendencia continúa. Estos son los resultados cuando miramos un espacio:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
>>> split_string_bug = """
split_string_bug(one_space)
"""
>>> split_string = """