1. Introducción
Cuando alguien dice «eliminar un elemento», la mente imagina algo sencillo: tomar y eliminar. Pero std::vector no es una caja mágica donde los elementos flotan. Es una secuencia contigua de celdas de memoria. Eliminar casi siempre implica «mover la cola», y eso afecta a iteradores, índices e incluso a la legibilidad del código. Así que no tenemos una única tarea, sino al menos tres escenarios distintos.
El primer escenario es puntual: «eliminar un elemento concreto» (por ejemplo, una tarea por id). El segundo es masivo: «eliminar todos los elementos que cumplan una condición» (por ejemplo, todas las tareas completadas). El tercero es funcional en espíritu: «construir una nueva lista según reglas, sin tocar la original» (por ejemplo, obtener la lista de tareas activas o la lista de cadenas para imprimir). En cada escenario la herramienta adecuada será distinta — y eso es lo que ahorra horas de depuración y hace el código predecible.
Para que no sea una abstracción, continuamos con nuestro mini-proyecto: un tracker de tareas en consola. Guardamos tareas en std::vector, cada una tiene id, title y un estado.
#include <string>
enum class TaskStatus { Todo, Done };
struct Task {
int id{};
std::string title;
TaskStatus status{TaskStatus::Todo};
};
2. Estrategia n.º 1: eliminación puntual mediante erase
Si necesitas eliminar exactamente un elemento, el enfoque más legible suele ser encontrarlo y borrarlo. Suena trivial, pero aquí importa la disciplina: eliminamos no por «un índice que guardamos en algún sitio», sino por un iterador que obtuvimos ahora mismo. Esto reduce la posibilidad de borrar «lo incorrecto» después de otras operaciones sobre la lista.
Empecemos con la función de búsqueda. Ya sabemos usar std::find_if, y las lambdas también son conocidas — así que se lee de forma bastante natural.
#include <algorithm>
#include <vector>
bool remove_task_by_id(std::vector<Task>& tasks, int id) {
auto it = std::find_if(tasks.begin(), tasks.end(),
[id](const Task& t) { return t.id == id; });
if (it == tasks.end()) return false;
tasks.erase(it);
return true;
}
Fijaos en un detalle importante: comprobamos que it != end(). Esto no es «precaución excesiva», es el contrato normal de la STL: no se puede desreferenciar end(), no se puede borrar a través de él, y en general es un límite, no un elemento.
A veces los principiantes quieren hacer esto: «bueno, conozco el índice, vamos a hacer erase(begin() + idx)». Funciona, pero sólo hasta que el índice deja de estar actualizado. En proyectos esto suele ir así: calculaste el índice, luego en algún momento borraste otro elemento, y después borras por el índice antiguo. El resultado es una eliminación con «historia»: pediste quitar la tarea "Buy milk", y desaparece "Pay rent". Y, curiosamente, el compilador no protesta.
Si de verdad trabajas con índices, al menos conviértelos en un iterador justo antes de borrar y mantenlo en la misma función, para no tener un «índice-viajero».
#include <vector>
bool remove_task_by_index(std::vector<Task>& tasks, std::size_t idx) {
if (idx >= tasks.size()) return false;
tasks.erase(tasks.begin() + static_cast<std::ptrdiff_t>(idx));
return true;
}
Aquí comprobamos los límites conscientemente, y sí, el cast parece algo «administrativo». Es una indicación de que la variante con find_if suele ser más sencilla y segura.
Otro punto importante sobre el «coste por intuición». Una operación erase(it) en vector casi siempre implica que los elementos a la derecha del elemento borrado se desplazan una posición a la izquierda. Es decir, no es un «clic» O(1), sino una operación que mueve la cola. Pero si lo haces una vez — está bien. El problema aparece cuando lo haces cientos de veces en un bucle. Entonces vamos al siguiente apartado.
3. Estrategia n.º 2: eliminación masiva mediante erase-remove
Cuando hay que eliminar todos los elementos que cumplen una condición, el camino «naïve» es iterar y llamar a erase cada vez que la condición es verdadera. A veces incluso lo intentan con range-for (lo cual suele terminar mal). Incluso si escribes bien el bucle con iteradores, queda el segundo problema: harás muchos desplazamientos de cola. Cuantas más eliminaciones, más «mezcla» de elementos, y terminas con una sensación de O(N²) oculta.
La idiom erase-remove lo resuelve elegantemente: primero, en una pasada agrupamos los «buenos» al principio, y luego con un único erase recortamos la cola. Es decir, en lugar de docenas o cientos de desplazamientos hacemos un único «desplazamiento masivo» (en realidad asignaciones/movimientos) y una sola reducción del tamaño.
Supongamos el caso: eliminar todas las tareas con estado Done.
#include <algorithm>
#include <vector>
int remove_done_tasks(std::vector<Task>& tasks) {
auto new_end = std::remove_if(tasks.begin(), tasks.end(),
[](const Task& t) {
return t.status == TaskStatus::Done;
});
int removed = static_cast<int>(tasks.end() - new_end);
tasks.erase(new_end, tasks.end());
return removed;
}
Fijaos la «filosofía» del predicado: remove_if pregunta no «a quién conservar», sino «a quién eliminar». Si la lambda devuelve true, el elemento se considera «malo» y será desplazado hacia la cola.
Es útil tener en mente esta imagen:
Antes de remove_if:
[ A ][ B ][ C ][ D ][ E ][ F ]
Después de remove_if (lógicamente):
[ A ][ C ][ E ][ ? ][ ? ][ ? ]
^ new_end
Después de erase(new_end, end):
[ A ][ C ][ E ]
Los signos de interrogación no son basura en el sentido de «memoria rota», pero definitivamente no son tus datos actuales. Pueden contener valores antiguos, duplicados, lo que sea, y no deben leerse como resultado.
Otra sutileza diaria: tras remove_if el tamaño (tasks.size()) no cambia. Si olvidas el segundo paso erase, al imprimir puedes ver «colas extrañas». Y el alumno suele decir: «mi remove_if no funciona». En realidad funciona, simplemente te quedaste a mitad de camino.
Si quieres imprimir el resultado justo después de remove_if, imprime sólo el rango [begin, new_end).
#include <iostream>
#include <algorithm>
#include <vector>
void debug_print_prefix(const std::vector<Task>& tasks) {
auto new_end = std::remove_if(tasks.begin(), tasks.end(),
[](const Task& t) {
return t.status == TaskStatus::Done;
});
for (auto it = tasks.begin(); it != new_end; ++it) {
std::cout << it->id << ": " << it->title << '\n';
}
}
Sí, es un estilo «de depuración»: usamos temporalmente el conocimiento de new_end para asegurarnos de que la lógica funciona.
4. Estrategia n.º 3: rebuild — construir un nuevo resultado
A veces el mayor problema al eliminar no es la velocidad ni la invalidación, sino la legibilidad. Especialmente cuando alrededor de la eliminación aparecen reglas adicionales: «eliminar completadas, pero solo si son anteriores a…», «eliminar títulos vacíos», «eliminar duplicados…». Empiezas a mutar el vector, a seguir el iterador a la vez, y llega un momento en que el código parece «manéjese con cuidado» (y eso en un buen día).
El enfoque rebuild significa: no tocamos el contenedor original, construimos uno nuevo. Es parecido a cuando rehaces un apuntes limpio: no arrastras la página vieja, simplemente pasas lo necesario.
Filtrado: copy_if y tareas activas
Supongamos que queremos la lista sólo de tareas activas (estado Todo), y queremos preservar la lista original (por ejemplo, para historial).
#include <algorithm>
#include <vector>
std::vector<Task> build_active_tasks(const std::vector<Task>& tasks) {
std::vector<Task> active(tasks.size());
auto out_end = std::copy_if(tasks.begin(), tasks.end(), active.begin(),
[](const Task& t) {
return t.status == TaskStatus::Todo;
});
active.erase(out_end, active.end());
return active;
}
Sí, aquí hay una «doble etapa», similar al erase-remove, pero el sentido es distinto. No «eliminamos del original», sino que «construimos uno nuevo». El segundo paso erase(out_end, end) simplemente acorta active, porque preasignamos el espacio máximo.
Los principiantes suelen querer escribir std::vector<Task> active; y luego llamar copy_if(..., active.begin(), ...). Pero begin() de un vector vacío no apunta a un lugar escribible. Por eso o bien preparamos el tamaño, o usamos técnicas con inserters (tema para otra lección).
Transformación: transform y cadenas para imprimir
std::transform no es para eliminar, sino para convertir un conjunto en otro. En nuestra «selección de estrategias» va junto al rebuild porque a menudo la tarea real no es «eliminar», sino «crear una vista».
Por ejemplo, queremos obtener una lista de cadenas para imprimir las tareas en formato "[id] title".
#include <algorithm>
#include <string>
#include <vector>
std::vector<std::string> build_task_lines(const std::vector<Task>& tasks) {
std::vector<std::string> lines(tasks.size());
std::transform(tasks.begin(), tasks.end(), lines.begin(),
[](const Task& t) {
return "[" + std::to_string(t.id) + "] " + t.title;
});
return lines;
}
Aquí no hay reducción de tamaño, porque transform hace «uno a uno». Es una transformación pura: tantas tareas, tantas líneas.
A veces es conveniente combinar filtrado y transformación en dos pasos: primero build_active_tasks, luego build_task_lines. A menudo se lee mejor que «un bucle enorme de 40 líneas con ifs».
5. Cómo elegir la estrategia: legibilidad y «coste por intuición»
Cuando conoces las tres estrategias, te preguntas: «¿cuál es la correcta?». La respuesta es algo molesta, pero honesta: la correcta es la que encaja con la tarea y hace el código más simple, no más ingenioso. No escribimos código para una competición de trucos con iteradores. Escribimos código que dentro de una semana leerá otra persona (quizá tú mismo, pero cansado).
Abajo hay una tabla que suele salvarte de dudar «¿y si siempre usar erase-remove?»:
| Escenario | Qué queremos hacer | Estrategia | Forma típica | «Coste por intuición» |
|---|---|---|---|---|
| Eliminar un elemento | «Eliminar la tarea con id=42» | Eliminación puntual erase | find_if → erase(it) | Un desplazamiento de la cola |
| Eliminar muchos elementos por condición | «Eliminar todos los Done» | erase-remove | new_end = remove_if(...) → erase(new_end, end) | Una pasada + un recorte |
| No mutar la entrada / obtener una nueva vista | «Construir la lista de activas» / «Crear cadenas para imprimir» | rebuild | copy_if / transform | Normalmente una pasada y mínimos riesgos |
También es útil tener una sencilla diagrama de flujo en mente. No sustituye al pensamiento, pero a veces ayuda cuando estás atascado.
flowchart TD
A[Necesitas 'eliminar'] --> B{¿Eliminar exactamente 1 elemento?}
B -- Sí --> C["find_if + erase(it)"]
B -- No --> D{"¿Necesitas mutar el vector original?"}
D -- Sí --> E["remove_if + erase(new_end, end)"]
D -- No --> F[rebuild: copy_if / transform]
Ahora unas palabras sobre «complejidad por intuición». Si eliminas elementos uno por uno muchas veces, vector moverá la cola repetidamente. Es similar a intentar sacar de una estantería cada tercer libro y después de cada extracción mover cuidadosamente los demás para no dejar huecos. Se puede hacer, pero será lento y doloroso.
Por eso «muchos erase individuales» es un olor de código. A veces está justificado (por ejemplo, borras un par de elementos por comandos del usuario), pero si limpias masivamente un contenedor, la idiom erase-remove suele ganar tanto en rendimiento como en expresividad.
6. Mini-proyecto: comandos de eliminación en TaskTracker
Para asentar la elección de estrategia, imaginemos que nuestro TaskTracker tiene tres comandos: eliminar por id, limpiar completadas y mostrar solo las activas (sin borrarlas de la base).
Abajo hay pequeñas funciones que puedes invocar desde tu menú/bucle de comandos (no describimos el bucle en sí, ya lo trataste en días anteriores).
Eliminar una tarea por id (estrategia puntual):
#include <iostream>
#include <vector>
void cmd_delete_by_id(std::vector<Task>& tasks, int id) {
if (remove_task_by_id(tasks, id)) {
std::cout << "Deleted task id=" << id << "\n";
} else {
std::cout << "No task with id=" << id << "\n";
}
}
Limpieza masiva de completadas (erase-remove):
#include <iostream>
#include <vector>
void cmd_clear_done(std::vector<Task>& tasks) {
int removed = remove_done_tasks(tasks);
std::cout << "Removed done tasks: " << removed << "\n";
}
Mostrar activas (rebuild con copy_if):
#include <iostream>
#include <vector>
void cmd_show_active(const std::vector<Task>& tasks) {
auto active = build_active_tasks(tasks);
for (const auto& t : active) {
std::cout << t.id << ": " << t.title << "\n";
}
}
Y si quieres «salida bonita» como una lista de cadenas (rebuild con transform):
#include <iostream>
#include <vector>
void cmd_print_lines(const std::vector<Task>& tasks) {
auto lines = build_task_lines(tasks);
for (const auto& line : lines) {
std::cout << line << "\n";
}
}
Observa el efecto agradable: cada comando se lee como una sola idea. Dentro hay una llamada a la estrategia correcta. Ese es el objetivo de la lección de hoy: no «conocer remove_if», sino poder elegir la forma de resolver para que el código esté tranquilo.
7. Errores típicos al elegir la estrategia de eliminación
Error n.º 1: usar erase puntual en una tarea masiva y obtener un “infierno cuadrático” oculto.
Cuando hay que eliminar la mitad de los elementos y haces erase muchas veces, vector moverá la cola una y otra vez. El código puede parecer corto y «lógico», pero empeorará con el crecimiento de los datos. Solución: si eliminas muchos por condición — casi siempre usa erase-remove (remove_if + erase).
Error n.º 2: pensar que remove_if «ya ha borrado» elementos del vector.
Después de remove_if el resultado real está solo en el prefijo [begin, new_end), y size() sigue igual. Si olvidas erase(new_end, end), verás la «cola» y pensarás que el algoritmo está roto. Solución: recordar la doble etapa — primero comprimir lógicamente, luego acortar físicamente.
Error n.º 3: confundir «a quién eliminar» y «a quién conservar» en el predicado de remove_if.
En remove_if el predicado devuelve true para quien debe irse. Los principiantes suelen escribir la «condición de conservar» y luego se sorprenden de lo que se elimina. Solución: en voz alta: «return true si el elemento es malo».
Error n.º 4: construir un nuevo vector con copy_if sin preparar espacio para escribir.
std::copy_if escribe en un rango de salida. Si haces std::vector<Task> dst; y pasas dst.begin(), la escritura va «a ninguna parte». Solución dentro del tema: hacer dst(src.size()) y luego dst.erase(out_end, dst.end()).
Error n.º 5: mezclar mutación y lectura hasta volver la lógica frágil.
Un típico «combinado»: en el mismo bucle imprimes, eliminas, acumulas estadísticas y además actualizas índices. Es difícil de testear y fácil de romper. Solución: separar responsabilidades. Si necesitas eliminar — usa una de las tres estrategias; la recolección de estadísticas/impresión hazla en otro paso o función.
GO TO FULL VERSION