Temas:
Motivación
Se tiene un archivo post.dat con los datos de los alumnos que dieron la PAA. Cada línea de este archivo contiene el carnet de identidad (c.i.) y el nombre de un postulante. Se tiene un segundo archivo mat.dat con los puntajes de los alumnos en la prueba específica de matemáticas. Cada línea contiene el c.i. de un alumno seguido de su puntaje. Por ejemplo, el contenido de ambos archivos podría ser:
post.dat mat.dat
-------- -------
11456789-6:Juan Gonzalez:... 12245894-7:715
12245894-7:Pedro Fernandez:... 11478387-1:801
... ...
El problema consiste en producir un archivo post-mat.dat que contenga una línea por cada postulante que da la prueba de matemáticas con su nombre y su puntaje. Por ejemplo:
12245894-7:Pedro Fernandez:715
11478387-1:Alberto Fuenzalida:612
...
Por esta razón, en la biblioteca del curso se incluyó un mecanismo más robusto para leer archivos con campos de datos. La idea es que los archivos se deben estructurar de modo que los campos de datos estén separados por un delimitador (típicamente ":"). Este delimitador se especifica al crear el lecto o el escritor del archivo. Al leer un archivo, la clase TextReader se encarga transparentemente de ubicar el delimitador y extraer el campo de datos. Del mismo modo, al escribir en el archivo, la clase TextWriter agrega transparentemente el caracter delimitador para separar los campos.
A modo de ejemplo, el siguiente código lee el archivo post.dat y deja el contenido en dos arreglos de nombres cis y nombres:
En general, el patrón de lectura que usaremos de acá en adelante es:
TextReader lect= new TextReader("post.dat", ":");
String[ ] cis= new String[1000];
String[ ] nombres= new String[1000];
int i= 0;
while (!lect.eofReached()) {
cis[i]= lect.readString();
nombres[i]= lect.readString();
lect.skip();
i++;
}
int nalum= i;
Del mismo modo, para escribir en un archivo separado con delimitadores
se usará el patrón:
TextReader lect= new TextReader(... nombre ..., ":");
...
while (!lect.eofReached()) { // termina si se acabó el archivo
...= lect.read...(); // lee un campo
...= lect.read...(); // lee otro campo
... // el resto de los campos
lect.skip(); // cambia de línea
... // procesa la línea
}
TextWriter escr= new TextWriter(... nombre ..., ":");
...
while (...) {
...
escr.print(...); // escribe un campo
escr.print(...); // escribe otro campo
...
escr.println(...); // escribe el último campo y cambia de línea
}
Primera solución (ineficiente)
Por cada línea del archivo mat.dat:
Con esto, el programa no cambia en esencia, solo que ahora se busca cada c.i. en memoria y no en disco. Como accesar datos en memoria es más eficiente que accesarlos en disco, se puede disminuir el tiempo en un factor 10. Es decir reducir el tiempo a poco menos de 3 horas (lo que todavía es inaceptable).
Para resolver el problema , se decide escribir una función que busca un string x en un arreglo a y entrega en qué posición se encuentra:
Este tipo de búsqueda se denomina búsqueda secuencial en
arreglos, porque se visitan los elementos en secuencia: el elemento 0,
el 1, 2, ..., y así hasta encontrar el elemento que contiene el valor
buscado.
int buscarSec(String x, String[ ] a, int n) {
int i= 0;
while (i<n) {
if (compare(x, a[i])==0)
return i;
i= i+1;
}
return -1; // No se encuentra en el arreglo
}
¿Cuantas iteraciones toma el ciclo?
lect= new TextReader("mat.dat", ":");
TextWriter escr= new TextWriter("post-mat.dat", ":");
while (!lect.eofReached()) {
String carnet= lect.readString();
int ptje= lect.readInt();
lect.skip();
// Obtenemos la posicion de carnet en el arreglo cis
int k= buscarSec(carnet, cis, nalum);
if (k==-1)
println("no se encontro el carnet "+carnet);
else {
escr.print(carnet);
escr.print(nombres[k]);
escr.println(ptje);
}
}
Para buscar x en un arreglo a:
Este tipo de búsqueda se llama búsqueda binaria, porque en cada
iteración se divide por 2 el tamaño del intervalo de búsqueda.
int buscarBin(String x, String[ ] a, int n) {
int imin= 0;
int imax= n-1;
while (imin<=imax) {
int icentro= (imin+imax)/2;
int comp= compare(x, a[icentro]);
if (comp==0)
return icentro;
if (comp<0)
imax= icentro-1;
else
imin= icentro+1;
}
return -1; // No se encuentra en el arreglo
}
Observe que la función tiene más líneas que la búsqueda secuencial y que para realizar cada iteración hay que realizar más trabajo. La pregunta es entonces ¿Cuál es mejor?
Normalmente, el criterio que se usa para comparar algoritmos son los tiempos de ejecución para valores de n grandes, siendo n el tamaño de los datos.
Notación O(f(n))
Para indicar cuál es la complejidad de un algoritmo se usa la notación O(f(n)). Se dice que un algoritmo toma tiempo O(f(n)) si existe un k y c tal que para todo n>=k siempre se verifica:
Esto significa que el tiempo de ejecución crece a lo más al ritmo de f(n) salvo por un factor constante.
Por lo tanto, la búsqueda secuencial toma, en el peor caso, tiempo O(n), es decir tiempo proporcional al número de elementos en el arreglo. En el caso promedio toma tiempo O(n/2) pero esto es lo mismo que O(n).
Por lo tanto, si se necesita buscar un elemento, la búsqueda secuencial toma un tiempo razonable, pero no olvide que para resolver nuestro problema inicial se necesitan realizar 100000 búsquedas de este tipo, con n=150000.
Análisis:
Para el caso en que n es 150000, una búsqueda binaria necesitará sólo 18 iteraciones del ciclo (en el peor caso). Es decir 4000 veces menos iteraciones que en la búsqueda secuencial. Pero cada iteración ahora es un poco más compleja. Digamos 2 veces más compleja y por lo tanto toma el doble de tiempo. Comparemos entonces los tiempos al realizar 100 mil búsquedas secuenciales y 100 mil búsquedas binarias:
Tipo de | Iteraciones en | Iteraciones | Costo por | Tiempo |
---|---|---|---|---|
Búsqueda | función de n | cuando n=150000 | iteración | Total |
Secuencial | O(n) | 75000 (promedio) | 1 | 2 horas |
Binaria | O(log2 n) | 18 | 2 | 3.6 segundos |
Problema de la búsqueda binaria: se necesita ordenar previamente el arreglo por carnet de identidad. Pronto veremos que existen algoritmos de ordenamiento muy eficientes con tiempos de ejecución O(n log n).
Conclusión
Cuando se comparan algoritmos para resolver un mismo problema, lo esencial es comparar la velocidad de crecimiento de sus tiempos de ejecución y no cuantas líneas tiene cada programa o cuan compleja resulta cada iteración. La velocidad de crecimiento del tiempo de ejecución se expresa por medio de la notación O(f(n)). Con esto, no se está diciendo que el tiempo es exactamente f(n), si no que a lo más crece a la velocidad de f(n).
Si dos algoritmos F y G toman tiempo O(f(n)) y O(g(n)) respectivamente. Se dice que F es mejor que G si:
Por lo tanto la búsqueda binaria es mejor que la búsqueda secuencial porque log(n)/n tiende a 0. En la práctica esto se traduce en que no importa que un algoritmo F sea más lento que G para valores de n pequeños, lo que es realmente relevante para los usuarios es qué algoritmo se comporta mejor cuando deben procesar grandes volúmenes de datos.
Observación
El problema de determinar la velocidad de crecimiento del tiempo de ejecución de un algoritmo es uno de los problemas complejos de la computación. Por lo general es incluso más difícil que programar el algoritmo. En este curso, nos limitaremos a estimar esta velocidad para algoritmos simples (y no se preguntará durante los controles).
Análisis: este algoritmo es idéntico al de la búsqueda binaria no
recursiva por lo tanto los tiempos de ejecución son equivalentes,
excepto por un factor constante. En efecto, la solución recursiva se
obtiene reemplazando las iteraciones de la solución no recursiva por
una llamada a una función. Las llamadas a funciones son ligeramente
más caras que las iteraciones en un ciclo.
int buscarBinRec(String x, String[] a, int imin, int imax) {
if (imin>imax)
return -1;
int icentro= (imin+imax)/2;
int comp= compare(x, a[icentro]);
if (comp==0)
return icentro;
if (comp<0)
return buscarBinRec(x, a, imin, icentro-1);
else
return buscarBinRec(x, a, icentro+1, imax);
}
Por lo tanto, si la preocupación es la eficiencia en tiempo de ejecución, se debería preferir las soluciones no recursivas. Sin embargo, existen problemas que es muy difícil llevar a soluciones no recursivas (piense en las torres de Hanoi). Cuando la preocupación es la claridad de una solución, es frecuente que se opte más bien por la recursividad.