Cómo ordenar correctamente cadenas con números incrustados

Utiliza la colación numérica para ordenar nombres de archivos, números de versión y otras cadenas que contienen números en orden natural

Introducción

Cuando ordenas cadenas que contienen números, esperas que file1.txt, file2.txt y file10.txt aparezcan en ese orden. Sin embargo, la comparación predeterminada de cadenas en JavaScript produce file1.txt, file10.txt, file2.txt. Esto ocurre porque las cadenas se comparan carácter por carácter, y el carácter 1 en 10 viene antes que el carácter 2.

Este problema aparece siempre que ordenas nombres de archivos, números de versión, direcciones, códigos de productos o cualquier otra cadena con números incrustados. El orden incorrecto confunde a los usuarios y hace que los datos sean difíciles de navegar.

JavaScript proporciona la API Intl.Collator con una opción numérica que resuelve este problema. Esta lección explica cómo funciona la ordenación numérica, por qué falla la comparación predeterminada de cadenas y cómo ordenar cadenas con números incrustados en un orden numérico natural.

Qué es la ordenación numérica

La ordenación numérica es un método de comparación que trata las secuencias de dígitos como números en lugar de caracteres individuales. Al comparar cadenas, el ordenador identifica secuencias de dígitos y las compara por su valor numérico.

Con la ordenación numérica desactivada, la cadena file10.txt viene antes que file2.txt porque la comparación carácter por carácter encuentra que 1 viene antes que 2 en la primera posición diferente. El ordenador nunca considera que 10 representa un número mayor que 2.

Con la ordenación numérica activada, el ordenador reconoce 10 y 2 como números completos y los compara numéricamente. Como 10 es mayor que 2, file2.txt correctamente viene antes que file10.txt.

Este comportamiento produce lo que la gente llama ordenación natural u orden natural, donde las cadenas que contienen números se ordenan como los humanos esperan que lo hagan en lugar de estrictamente alfabéticamente.

Por qué falla la comparación predeterminada de cadenas para números

La comparación de cadenas predeterminada de JavaScript utiliza ordenamiento lexicográfico, que compara las cadenas carácter por carácter de izquierda a derecha utilizando los valores de código Unicode. Esto funciona correctamente para texto alfabético pero produce resultados inesperados para números.

Consideremos cómo la comparación lexicográfica maneja estas cadenas:

const files = ['file1.txt', 'file10.txt', 'file2.txt', 'file20.txt'];
files.sort();

console.log(files);
// Resultado: ['file1.txt', 'file10.txt', 'file2.txt', 'file20.txt']

La comparación examina cada posición de carácter de forma independiente. En la primera posición diferente después de file, compara 1 contra 2. Como 1 tiene un valor Unicode menor que 2, cualquier cadena que comience con file1 viene antes que cualquier cadena que comience con file2, independientemente de lo que siga.

Esto produce la secuencia file1.txt, file10.txt, file2.txt, file20.txt, que viola las expectativas humanas sobre el ordenamiento numérico.

Usando Intl.Collator con la opción numeric

El constructor Intl.Collator acepta un objeto de opciones con una propiedad numeric. Establecer numeric: true habilita la ordenación numérica, haciendo que el comparador compare secuencias de dígitos por su valor numérico.

const collator = new Intl.Collator('en-US', { numeric: true });
const files = ['file1.txt', 'file10.txt', 'file2.txt', 'file20.txt'];

files.sort(collator.compare);

console.log(files);
// Resultado: ['file1.txt', 'file2.txt', 'file10.txt', 'file20.txt']

El método compare del comparador devuelve un número negativo cuando el primer argumento debe ir antes que el segundo, cero cuando son iguales, y un número positivo cuando el primero debe ir después del segundo. Esto coincide con la firma esperada por el método Array.sort() de JavaScript.

El resultado ordenado coloca los archivos en orden numérico natural. El comparador reconoce que 1 < 2 < 10 < 20, produciendo la secuencia que los humanos esperan.

Ordenando cadenas alfanuméricas mixtas

La ordenación numérica maneja cadenas donde los números aparecen en cualquier posición, no solo al final. El ordenador compara las partes alfabéticas normalmente y las partes numéricas numéricamente.

const collator = new Intl.Collator('en-US', { numeric: true });
const addresses = ['123 Oak St', '45 Oak St', '1234 Oak St', '5 Oak St'];

addresses.sort(collator.compare);

console.log(addresses);
// Output: ['5 Oak St', '45 Oak St', '123 Oak St', '1234 Oak St']

El ordenador identifica las secuencias de dígitos al principio de cada cadena y las compara numéricamente. Reconoce que 5 < 45 < 123 < 1234, aunque la comparación lexicográfica produciría un orden diferente.

Ordenación de números de versión

Los números de versión son un caso de uso común para la ordenación numérica. Las versiones de software como 1.2.10 deberían aparecer después de 1.2.2, pero la comparación lexicográfica produce un orden incorrecto.

const collator = new Intl.Collator('en-US', { numeric: true });
const versions = ['1.2.10', '1.2.2', '1.10.5', '1.2.5'];

versions.sort(collator.compare);

console.log(versions);
// Output: ['1.2.2', '1.2.5', '1.2.10', '1.10.5']

El ordenador compara cada componente numérico correctamente. En la secuencia 1.2.2, 1.2.5, 1.2.10, reconoce que el tercer componente aumenta numéricamente. En 1.10.5, reconoce que el segundo componente es 10, que es mayor que 2.

Trabajando con códigos de producto e identificadores

Los códigos de producto, números de factura y otros identificadores a menudo mezclan letras con números. La ordenación numérica asegura que estos se ordenen de manera lógica.

const collator = new Intl.Collator('en-US', { numeric: true });
const products = ['PROD-1', 'PROD-10', 'PROD-2', 'PROD-100'];

products.sort(collator.compare);

console.log(products);
// Output: ['PROD-1', 'PROD-2', 'PROD-10', 'PROD-100']

El prefijo alfabético PROD- coincide en todas las cadenas, por lo que el ordenador compara el sufijo numérico. El resultado refleja un orden numérico creciente en lugar de un orden lexicográfico.

Ordenación con diferentes locales

La opción numeric funciona con cualquier locale. Aunque diferentes locales pueden tener diferentes reglas de ordenación para caracteres alfabéticos, el comportamiento de comparación numérica permanece consistente.

const enCollator = new Intl.Collator('en-US', { numeric: true });
const deCollator = new Intl.Collator('de-DE', { numeric: true });

const items = ['item1', 'item10', 'item2'];

console.log(items.sort(enCollator.compare));
// Output: ['item1', 'item2', 'item10']

console.log(items.sort(deCollator.compare));
// Output: ['item1', 'item2', 'item10']

Ambos locales producen el mismo resultado porque las cadenas contienen solo caracteres ASCII y números. Cuando las cadenas incluyen caracteres específicos del locale, la comparación alfabética sigue las reglas del locale mientras que la comparación numérica permanece consistente.

Comparación de cadenas sin ordenar

Puedes usar el método compare del collator directamente para determinar la relación entre dos cadenas sin ordenar un array completo.

const collator = new Intl.Collator('en-US', { numeric: true });

console.log(collator.compare('file2.txt', 'file10.txt'));
// Output: -1 (número negativo significa que el primer argumento va antes que el segundo)

console.log(collator.compare('file10.txt', 'file2.txt'));
// Output: 1 (número positivo significa que el primer argumento va después del segundo)

console.log(collator.compare('file2.txt', 'file2.txt'));
// Output: 0 (cero significa que los argumentos son iguales)

Esto es útil cuando necesitas verificar el orden sin modificar un array, como al insertar un elemento en una lista ordenada o comprobar si un valor cae dentro de un rango.

Entendiendo la limitación con números decimales

La ordenación numérica compara secuencias de dígitos, pero no reconoce los puntos decimales como parte de los números. El carácter de punto se trata como un separador, no como un separador decimal.

const collator = new Intl.Collator('en-US', { numeric: true });
const measurements = ['0.5', '0.05', '0.005'];

measurements.sort(collator.compare);

console.log(measurements);
// Output: ['0.005', '0.05', '0.5']

El collator trata cada medida como tres componentes numéricos separados: la parte antes del punto, el punto mismo, y la parte después del punto. Compara 0 contra 0 (igual), luego compara las partes después del punto como números separados: 5, 5, y 5 (igual). Luego compara el segundo lugar decimal: nada, 5, y nada. Esto produce un orden incorrecto para números decimales.

Para ordenar números decimales, conviértelos a números reales y ordénalos numéricamente, o usa relleno de cadenas para asegurar un orden lexicográfico correcto.

Combinando la colación numérica con otras opciones

La opción numeric funciona junto con otras opciones de colación como sensitivity y caseFirst. Puedes controlar cómo el comparador maneja las mayúsculas y los acentos mientras mantiene el comportamiento de comparación numérica.

const collator = new Intl.Collator('en-US', {
  numeric: true,
  sensitivity: 'base'
});

const items = ['Item1', 'item10', 'ITEM2'];

items.sort(collator.compare);

console.log(items);
// Output: ['Item1', 'ITEM2', 'item10']

La opción sensitivity: 'base' hace que la comparación no distinga entre mayúsculas y minúsculas. El comparador trata Item1, item1 y ITEM1 como equivalentes mientras sigue comparando correctamente las partes numéricas.

Reutilización de comparadores para mejorar el rendimiento

Crear una nueva instancia de Intl.Collator implica cargar datos de localización y procesar opciones. Cuando necesites ordenar múltiples arrays o realizar muchas comparaciones, crea el comparador una vez y reutilízalo.

const collator = new Intl.Collator('en-US', { numeric: true });

const files = ['file1.txt', 'file10.txt', 'file2.txt'];
const versions = ['1.2.10', '1.2.2', '1.10.5'];
const products = ['PROD-1', 'PROD-10', 'PROD-2'];

files.sort(collator.compare);
versions.sort(collator.compare);
products.sort(collator.compare);

Este enfoque es más eficiente que crear un nuevo comparador para cada operación de ordenación. La diferencia de rendimiento se vuelve significativa cuando se ordenan muchos arrays o se realizan comparaciones frecuentes.