String #
En Java, le type String représente une séquence de caractères. Il est très utilisé pour manipuler du texte : noms, messages, fichiers, etc. Une particularité essentielle à comprendre est que les objets de type String sont immuables : une fois créés, ils ne peuvent pas être modifiés. Toute opération qui semble modifier une chaîne (comme la concaténation, le remplacement ou la suppression de caractères) crée en réalité un nouvel objet String en mémoire, sans changer l’original.
Par exemple :
String s = "Bonjour";
s = s + " le monde"; // Crée un nouvel objet String
Ici, la chaîne “Bonjour” n’est pas modifiée : une nouvelle chaîne “Bonjour le monde” est créée et la variable s pointe vers ce nouvel objet. L’ancienne chaîne reste inchangée (et sera éventuellement libérée par le ramasse-miettes).
Cette immuabilité rend les String sûres et efficaces pour le partage, mais peut entraîner des problèmes de performance si on fait beaucoup de modifications : dans ce cas, il vaut mieux utiliser StringBuilder.
En Java, les chaînes de caractères (String) sont représentées en mémoire selon l’encodage UTF-16. Cela signifie que chaque élément du tableau interne d’une chaîne est un « code unit » de 16 bits (un char Java), mais tous les caractères Unicode ne tiennent pas forcément dans un seul char.
L’UTF-16 est un encodage qui permet de représenter tous les caractères Unicode. La plupart des caractères courants (latin, accentués, etc.) sont codés sur un seul char (16 bits), mais certains caractères spéciaux ou emojis, appelés « supplémentaires », nécessitent deux char consécutifs (appelés une paire de substitution ou surrogate pair).
La méthode charAt(int index) retourne le char à la position donnée dans la chaîne, mais ce char ne correspond pas toujours à un caractère complet pour l’utilisateur. Si la chaîne contient un caractère supplémentaire (hors du plan multilingue de base), charAt peut retourner seulement une partie de ce caractère (un des deux éléments de la paire de substitution).
Pour manipuler correctement les caractères Unicode, il faut utiliser les méthodes codePointAt, codePoints() ou les classes de l’API Character, qui tiennent compte des paires de substitution et permettent de traiter chaque caractère Unicode comme une entité logique.
String s = "A😊B";
System.out.println(s.length()); // Affiche 4 (car 😊 occupe deux char)
System.out.println(s.charAt(1)); // Affiche un char de la paire surrogate, pas le smiley complet
System.out.println(s.codePointAt(1));// Affiche le code Unicode complet du smiley
Ainsi, il faut être vigilant lors du traitement de chaînes contenant des emojis ou des caractères spéciaux, car la longueur d’une chaîne (length) et l’accès par charAt ne correspondent pas toujours au nombre réel de caractères.
Utilisez l’application suivante pour explorer la représentation des chaînes de caractères en format UTF-16. Vous pouvez taper des caractères et voir comment la chaîne de caractère est représentée en mémoire.
Voici un exemple en Java qui illustre la plupart des propriétés et méthodes de la classe String.
La méthode split de la classe String en Java est utilisée pour diviser une chaîne en un tableau de sous-chaînes en fonction d’un délimiteur spécifié, qui peut être une chaîne simple ou une expression régulière. Par exemple, split("\\s+") divise une chaîne sur un ou plusieurs espaces, tandis que split(",") utilise une virgule comme séparateur. L’expression \\s+ signifie ‘un ou plusieurs espaces.
Nous pourrions utiliser split(";") pour diviser sur un point-virgule.
Nous donnons plus loin un aperçu des expressions régulières. La notion n’est toutefois pas approfondie dans ce cours.
Les principales méthodes de la classe String #
La classe String offre plusieurs dizaines de méthodes. Les tableaux qui suivent
décrivent les plus courantes et indiquent, lorsque la question se pose, leur complexité
algorithmique, c’est-à-dire l’ordre de grandeur du nombre d’opérations effectuées.
Dans tout ce qui suit :
- \(n\) désigne la longueur de la chaîne sur laquelle la méthode est appelée ;
- \(m\) désigne la longueur de la chaîne passée en argument, lorsqu’il y en a une.
Rappelons qu’intuitivement \(O(1)\) signifie « un temps constant, indépendant de la taille de la chaîne », et que \(O(n)\) signifie « un temps proportionnel à la longueur de la chaîne ».
Accéder au contenu #
| Méthode | Description | Complexité |
|---|---|---|
length() | Nombre de char (unités de code UTF-16) de la chaîne. La valeur est stockée dans l’objet : elle n’est pas recalculée. | \(O(1)\) |
isEmpty() | Vrai si la chaîne ne contient aucun caractère. Équivaut à length() == 0. | \(O(1)\) |
isBlank() | Vrai si la chaîne est vide ou ne contient que des espaces. Il faut parcourir la chaîne. | \(O(n)\) |
charAt(int i) | Le char à la position i. Un accès direct dans un tableau. Attention : ce n’est pas toujours un caractère complet (voir les paires de substitution). | \(O(1)\) |
codePointAt(int i) | Le point de code Unicode complet commençant à la position i. | \(O(1)\) |
toCharArray() | Copie la chaîne dans un nouveau tableau de char. | \(O(n)\) |
chars(), codePoints() | Produisent un flux (IntStream) des unités de code ou des points de code. Créer le flux est immédiat ; le parcourir coûte \(O(n)\). | \(O(1)\) puis \(O(n)\) |
hashCode() | Valeur de hachage utilisée par HashMap et HashSet. Elle est calculée une seule fois puis conservée dans l’objet. | \(O(n)\) au premier appel, \(O(1)\) ensuite |
Comparer #
| Méthode | Description | Complexité |
|---|---|---|
equals(Object o) | Compare le contenu de deux chaînes. C’est la bonne façon de comparer des chaînes : l’opérateur == compare les références, pas le texte. Si les longueurs diffèrent, la réponse est immédiate. | \(O(n)\) au pire |
equalsIgnoreCase(String s) | Comme equals, mais sans tenir compte de la casse. | \(O(n)\) |
compareTo(String s) | Comparaison lexicographique (ordre du dictionnaire selon les valeurs Unicode). Retourne un entier négatif, nul ou positif. Utilisée pour trier. | \(O(\min(n,m))\) |
compareToIgnoreCase(String s) | Comme compareTo, sans tenir compte de la casse. | \(O(\min(n,m))\) |
startsWith(String p), endsWith(String p) | Vrai si la chaîne commence (ou se termine) par p. Une seule comparaison alignée : pas de recherche. | \(O(m)\) |
contentEquals(CharSequence cs) | Compare le contenu avec n’importe quelle séquence de caractères, par exemple un StringBuilder. | \(O(n)\) |
Rechercher #
| Méthode | Description | Complexité |
|---|---|---|
indexOf(int c), lastIndexOf(int c) | Position de la première (ou dernière) occurrence d’un caractère, ou -1. Un simple balayage. | \(O(n)\) |
indexOf(String s), lastIndexOf(String s) | Position de la première (ou dernière) occurrence d’une sous-chaîne, ou -1. | \(O(n\,m)\) |
indexOf(String s, int depart) | Comme ci-dessus, mais la recherche commence à la position depart. Pratique pour énumérer toutes les occurrences. | idem |
contains(CharSequence cs) | Vrai si la sous-chaîne apparaît. Équivaut à indexOf(cs.toString()) >= 0 et hérite donc de son coût. | \(O(n\,m)\) |
Transformer #
Toutes ces méthodes retournent une nouvelle chaîne : la chaîne d’origine n’est jamais modifiée.
| Méthode | Description | Complexité |
|---|---|---|
substring(int a), substring(int a, int b) | Extrait les caractères de a (inclus) à b (exclu). Le contenu est copié : le coût est proportionnel à la longueur extraite. | \(O(b-a)\) |
concat(String s) et l’opérateur + | Assemble deux chaînes dans une nouvelle chaîne. Concaténer dans une boucle est donc coûteux : préférez StringBuilder. | \(O(n+m)\) |
replace(char a, char b) | Remplace toutes les occurrences d’un caractère par un autre. Un seul balayage. | \(O(n)\) |
replace(CharSequence a, CharSequence b) | Remplace toutes les occurrences d’une sous-chaîne. Répète une recherche de sous-chaîne. | \(O(n\,m)\) au pire |
toUpperCase(), toLowerCase() | Changement de casse, selon les règles de la langue courante (Locale). | \(O(n)\) |
trim() | Retire les caractères de code inférieur ou égal à l’espace au début et à la fin. | \(O(n)\) |
strip(), stripLeading(), stripTrailing() | Comme trim(), mais en reconnaissant tous les espaces Unicode. À privilégier (Java 11+). | \(O(n)\) |
repeat(int k) | Répète la chaîne k fois. | \(O(n\,k)\) |
intern() | Retourne l’exemplaire canonique de la chaîne, conservé dans une table interne de la JVM. À utiliser avec parcimonie. | \(O(n)\) |
Découper et assembler #
| Méthode | Description | Complexité |
|---|---|---|
split(String regex) | Découpe la chaîne selon une expression régulière et retourne un tableau. Java optimise le cas d’un séparateur d’un seul caractère ordinaire. | \(O(n)\) en pratique |
lines() | Produit un flux des lignes de la chaîne. | \(O(n)\) au parcours |
String.join(sep, parties) | Assemble des chaînes en les séparant par sep. Une seule allocation. | \(O(L)\), où \(L\) est la longueur totale |
String.format(modele, ...) | Construit une chaîne formatée (%s, %d, %.2f, etc.). | \(O(L)\) |
String.valueOf(x) | Convertit une valeur quelconque en chaîne. | dépend de x |
Dans ce cours, il n’est pas nécessaire de mémoriser ces complexités. Retenez surtout deux choses : l’accès à un caractère (
charAt) est immédiat, alors que presque toute autre opération parcourt la chaîne au complet. C’est pourquoi il faut éviter de reconstruire des chaînes à répétition à l’intérieur d’une boucle.
La recherche de sous-chaîne : indexOf
#
La méthode indexOf(String) répond à une question simple : à quelle position le motif
apparaît-il dans le texte ? L’approche la plus directe consiste à essayer chaque position
possible, l’une après l’autre :
// Recherche naïve : à ne pas utiliser en pratique, mais facile à comprendre.
static int rechercheNaive(String texte, String motif) {
int n = texte.length(), m = motif.length();
for (int position = 0; position + m <= n; position++) {
int j = 0;
while (j < m && texte.charAt(position + j) == motif.charAt(j)) {
j++;
}
if (j == m) return position; // le motif entier a concordé
}
return -1;
}
Sur du texte ordinaire, cette boucle s’arrête presque toujours dès la première lettre :
le coût est alors d’environ \(n\) comparaisons. Mais si le texte et le motif se
ressemblent beaucoup, chaque essai peut aller très loin avant d’échouer. Avec un texte
formé d’un million de a et un motif formé de 4096 a suivis d’un seul b, chacun des
\(n\) essais compare 4096 caractères avant d’échouer : on obtient \(n\,m\) comparaisons,
soit un comportement quadratique.
On pourrait croire que la bibliothèque standard de Java échappe à ce problème. Ce n’est
pas le cas : la mise en œuvre de String.indexOf est très optimisée. Mais elle a tout
de même une complexité de \(n\,m\) comparaisons dans le pire des cas. Voir à ce sujet
Java’s String.indexOf can be slow (quadratic).
Conséquence pratique : si votre programme cherche dans un texte un motif fourni par l’utilisateur, un motif très long peut suffire à le paralyser. La parade la plus simple est de refuser les motifs démesurément longs.
L’algorithme de Horspool #
Peut-on faire mieux que d’essayer chaque position ? Oui, et l’idée est étonnamment simple.
Elle est due à Nigel Horspool (1980), qui a proposé une version simplifiée de l’algorithme
de Boyer-Moore. Les mises en œuvre efficaces de la recherche de sous-chaîne — dans les
bibliothèques standard de plusieurs langages, dans les éditeurs de texte, dans les outils
comme grep — reposent souvent sur cet algorithme ou sur l’un de ses proches parents.
L’algorithme de Horspool tient en deux observations :
- On compare le motif de la droite vers la gauche, alors qu’on le fait glisser vers la droite.
- Lorsqu’une comparaison échoue, on regarde le caractère du texte qui était aligné avec la dernière lettre du motif. Ce caractère nous dit de combien on peut glisser sans risquer de rater une occurrence : si ce caractère n’apparaît nulle part dans le motif, on peut glisser de toute la longueur du motif d’un seul coup.
On prépare donc, avant la recherche, une petite table de décalage : pour chaque
caractère c, decalage[c] vaut la distance entre la dernière occurrence de c dans le
motif (sa dernière lettre exclue) et la fin du motif ; et vaut la longueur du motif si c
n’y figure pas.
Voyons l’algorithme à l’œuvre sur un petit exemple.
Trois alignements suffisent là où la recherche naïve en aurait essayé cinq. Surtout, l’algorithme n’a jamais lu les caractères des positions 0 et 1 du texte : il les a survolés. Sur un exemple aussi court le gain est modeste, mais il devient spectaculaire quand le motif s’allonge, comme nous le verrons.
Voici une mise en œuvre complète en Java. Le programme compte les comparaisons afin de rendre le travail effectué visible.
Un algorithme qui lit tout le texte fait au moins \(n\) opérations. Horspool, lui, peut en faire moins : c’est ce qu’on appelle un comportement sous-linéaire. La raison est qu’il ne lit pas tous les caractères du texte ; il en saute.
Prenons le cas le plus net. Le motif est xyz (trois lettres) et le texte ne contient que
des a. À chaque essai :
- on compare la dernière lettre du motif,
z, avec un caractère du texte,a: désaccord dès la première comparaison ; - le caractère
an’apparaît pas dans le motif, doncdecalage['a']vaut 3 : on glisse de trois positions d’un seul coup.
Chaque comparaison fait donc avancer de trois caractères. Pour un texte de \(n\) caractères, il n’en faut que \(n/3\). Plus généralement, avec un motif de longueur \(m\) dont les lettres sont rares dans le texte, le coût descend vers \(n/m\) comparaisons : plus le motif est long, plus la recherche est rapide. C’est exactement l’inverse de la recherche naïve.
Il existe des algorithmes dont le pire cas est garanti linéaire, c’est-à-dire \(O(n+m)\) : Knuth-Morris-Pratt (1977) et l’algorithme Two-Way de Crochemore et Perrin (1991), ce dernier étant utilisé par plusieurs bibliothèques C. Ils ne sont toutefois pas toujours plus rapides en pratique : sur du texte ordinaire, leur surcoût les rend souvent plus lents qu’une recherche simple. Il n’existe pas de meilleur algorithme dans l’absolu, seulement des compromis.
Les expressions régulières #
Une expression régulière (ou regex) est un petit langage qui sert à décrire une
forme de texte plutôt qu’un texte précis. Au lieu de chercher le mot exact « 2026-08-26 »,
on décrit « quatre chiffres, un tiret, deux chiffres, un tiret, deux chiffres ». Plusieurs
méthodes de String acceptent une expression régulière en argument.
Voici les éléments de syntaxe les plus courants. Attention : en Java, l’expression
régulière est écrite dans une chaîne de caractères, et la barre oblique inverse doit donc
être doublée. Pour exprimer « un chiffre », on écrit \d en notation regex, ce qui donne
"\\d" dans le code Java.
| Notation | Signification | Exemple |
|---|---|---|
. | n’importe quel caractère | "a.c" reconnaît abc, axc |
\d | un chiffre | "\\d\\d" reconnaît 42 |
\w | une lettre, un chiffre ou le trait de soulignement | "\\w+" reconnaît nom_1 |
\s | une espace, une tabulation, un saut de ligne | "\\s+" reconnaît une suite d’espaces |
[abc] | l’un des caractères énumérés | "[aeiou]" reconnaît une voyelle |
[a-z] | un caractère de l’intervalle | "[A-Z]" reconnaît une majuscule |
[^abc] | tout sauf les caractères énumérés | "[^0-9]" reconnaît un non-chiffre |
? | l’élément précédent, zéro ou une fois | "colou?r" reconnaît color et colour |
* | l’élément précédent, zéro fois ou plus | "ab*" reconnaît a, ab, abb |
+ | l’élément précédent, une fois ou plus | "\\d+" reconnaît 7, 2026 |
{n}, {n,m} | un nombre précis de répétitions | "\\d{4}" reconnaît 2026 |
(...) | un groupe, que l’on peut récupérer séparément | "(\\d{4})-(\\d{2})" |
a|b | l’un ou l’autre | "chat|chien" |
^, $ | début, fin de la chaîne | "^Bonjour" |
Les méthodes concernées de la classe String sont les suivantes.
| Méthode | Description | Complexité |
|---|---|---|
matches(String regex) | Vrai si la chaîne entière correspond au motif. Attention : ce n’est pas une recherche partielle. | \(O(n)\) en général, exponentielle au pire |
replaceAll(String regex, String remplacement) | Remplace toutes les portions qui correspondent au motif. | idem |
replaceFirst(String regex, String remplacement) | Ne remplace que la première portion. | idem |
split(String regex) | Découpe la chaîne aux endroits qui correspondent au motif. | idem |
Ces quatre méthodes recompilent l’expression régulière à chaque appel. Dans une boucle, il vaut donc mieux compiler le
motif une seule fois avec Pattern.compile et le réutiliser. La classe Matcher permet en
outre de récupérer les portions reconnues, ce que String ne sait pas faire.
Le moteur d’expressions régulières de Java procède par retour sur trace (backtracking) : quand une piste échoue, il revient en arrière et en essaie une autre. Sur certains motifs, le nombre de pistes explose et le temps de calcul devient exponentiel. L’exemple classique est le motif
(a+)+bconfronté à une chaîne formée uniquement dea:// À NE PAS EXÉCUTER tel quel : le temps de calcul double à chaque « a » ajouté. "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".matches("(a+)+b");Comme pour
indexOf, la leçon est la même : méfiez-vous des motifs et des textes fournis par l’utilisateur. Ce défaut porte un nom, ReDoS (déni de service par expression régulière).
StringBuilder #
Le type StringBuilder en Java permet de construire et de modifier efficacement des chaînes de caractères. Contrairement à la classe String, qui est immuable (chaque modification crée un nouvel objet), StringBuilder permet d’ajouter, de modifier ou de supprimer des caractères sans créer de nouveaux objets à chaque opération. Cela le rend particulièrement utile lorsqu’on doit faire de nombreuses modifications ou concaténations de chaînes, par exemple lors de la lecture d’un fichier ou la construction dynamique d’un texte.
L’utilisation de StringBuilder améliore considérablement les performances, surtout dans les boucles : concaténer des chaînes avec + dans une boucle crée à chaque fois une nouvelle chaîne, ce qui consomme beaucoup de mémoire et ralentit le programme. StringBuilder évite ce problème en travaillant sur une seule zone mémoire.
Exemple :
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 5; i++) {
sb.append("Ligne ").append(i).append("\n");
}
String resultat = sb.toString();
System.out.println(resultat);
Dans cet exemple, toutes les lignes sont ajoutées efficacement à la même chaîne. Pour des opérations répétées ou sur de gros volumes de texte, StringBuilder est donc le choix recommandé pour de bonnes performances.
Voici un exemple en Java qui illustre la plupart des propriétés et méthodes de la classe StringBuilder.
CharSequence et subSequence() #
L’interface CharSequence représente une séquence de caractères lisible : elle est implémentée par plusieurs classes Java comme String, StringBuilder et StringBuffer. Cela permet d’écrire des méthodes qui acceptent n’importe quel type de séquence de caractères, et pas seulement des chaînes immuables.
La méthode subSequence(int start, int end) permet d’obtenir une portion (sous-séquence) de la séquence de caractères, allant de l’indice start (inclus) à end (exclu). C’est utile pour extraire une partie d’un texte sans créer une nouvelle chaîne si ce n’est pas nécessaire.
Exemple avec String :
String texte = "Bonjour le monde";
CharSequence sousTexte = texte.subSequence(8, 14); // "le mon"
System.out.println(sousTexte);
Exemple avec StringBuilder :
StringBuilder sb = new StringBuilder("abcdefg");
CharSequence sousSeq = sb.subSequence(2, 5); // "cde"
System.out.println(sousSeq);
Utiliser CharSequence rend le code plus flexible : on peut manipuler des chaînes, des buffers ou des builders de la même façon, et extraire facilement des sous-parties avec subSequence(). La méthode subSequence évite de faire une copie inutile.
Allocation de mémoire et ramasse-miettes #
Comprendre l’allocation de mémoire et le ramasse-miettes n’est pas obligatoire dans ce cours.
Lorsque vous créez un objet en Java, la mémoire nécessaire est automatiquement allouée dans une zone appelée le « tas » (heap). Contrairement à certains langages comme C ou C++, il n’est pas nécessaire de libérer explicitement la mémoire des objets qui ne sont plus utilisés. Java intègre un mécanisme appelé ramasse-miettes (ou garbage collector) qui se charge de détecter et de libérer automatiquement la mémoire occupée par les objets devenus inaccessibles. Il partage cette caractéristique avec d’autres langages comme C#, JavaScript et Python.
Le ramasse-miettes fonctionne en arrière-plan : il identifie les objets qui ne sont plus référencés par aucune variable ou structure de données, puis récupère la mémoire correspondante pour la rendre disponible à de nouveaux objets. Cela simplifie la gestion de la mémoire et réduit les risques de fuites de mémoire (memory leaks) ou d’erreurs de libération (comme les double free en C).
Cependant, il est important de comprendre que la libération de la mémoire n’est pas instantanée : le ramasse-miettes intervient à des moments choisis par la machine virtuelle Java (JVM), ce qui peut parfois entraîner de légères pauses dans l’exécution du programme. Pour la plupart des applications, ce fonctionnement automatique est un avantage, car il permet de se concentrer sur la logique du programme sans se soucier de la gestion manuelle de la mémoire.
L’allocation de mémoire en Java est automatique et la libération est assurée par le ramasse-miettes, ce qui contribue à la robustesse et à la sécurité des programmes Java.
Par contre, le ramasse-miettes a des inconvénients : il peut provoquer des pauses imprévisibles dans l’exécution du programme, appelées « pauses de collecte », lorsque la JVM décide de libérer la mémoire. Ces pauses sont généralement courtes, mais peuvent devenir perceptibles dans des applications nécessitant une grande réactivité (jeux, systèmes temps réel, etc.). De plus, le développeur a moins de contrôle sur le moment précis où la mémoire est libérée, ce qui peut compliquer l’optimisation des performances dans certains cas particuliers. Enfin, le ramasse-miettes consomme lui-même des ressources processeur, ce qui peut avoir un effet sur l’efficacité globale du programme.
Malgré l’existence du ramasse-miettes, il faut donc tenter de minimiser l’allocation de mémoire. Il faut éviter de créer des objets temporaires quand on peut réutiliser un objet déjà alloué.
Considérons l’exemple suivant.
- Approche 1 (String) : À chaque itération, l’opérateur += crée un nouvel objet String, car les objets String sont immuables en Java. Cela génère de nombreux objets temporaires qui doivent être gérés par le ramasse-miettes, augmentant la charge mémoire et le temps d’exécution.
- Approche 2 (StringBuilder) : En utilisant StringBuilder, un seul objet est créé et modifié à chaque itération. Cela réduit considérablement le nombre d’allocations mémoire et la charge sur le ramasse-miettes, ce qui améliore les performances.