Monografias.com > Computación > Programación
Descargar Imprimir Comentar Ver trabajos relacionados

Algoritmos y Estructuras de Datos




Enviado por Pablo Turmero



Partes: 1, 2, 3, 4

Monografía destacada


    Nociones básicas de programación

    En esta sección se revisarán los elementos básicos que se van a utilizar para escribir programas. Esto supone que los alumnos ya saben programar, y es sólo un resumen y una ordenación de conceptos. La notación utilizada es la del lenguaje Java, pero los conceptos son más generales y se aplican a muchos otros lenguajes similares.

    Datos

    Los programas representan la información que manejan mediante valores llamados "constantes", y dichos valores se almacenan en "variables".

    Variables

    int k; // entero

    float x; // real

    double prom; // real de doble precisión

    boolean condicion; // verdadero o falso

    char c; // un carácter

    String nombre; // secuencia de caracteres

    Nótese que la secuencia "//" indica el comienzo de un comentario, el cual se extiende hasta el final de la línea.

    Constantes

    3 // int

    4.50 // float

    1e-6 // float

    'a' // char

    "hola" // String

    Instrucciones Elementales

    Asignación

    Esta es la instrucción más simple, que permite modificar el valor de una variable:

    a = E; // asigna a la variable 'a' el valor de la expresión 'E'

    Ejemplos:

    k = 0;

    k = k+1;

    k += 1;

    ++k;

    k++;

    Las tres últimas son abreviaturas. La notación "+=" permite evitar repetir el lado izquierdo de la asignación. Las dos últimas incrementan el valor de la variable en 1, pero difieren respecto del valor retornado. En el primer caso (preincremento) se incrementa primero y luego se retorna el valor resultante. El el segundo caso (postincremento) se incrementa después, y se retorna el valor previo de la variable.

    Salida

    System.out.println("¡Hola!");

    Instrucciones Compuestas

    Estas instrucciones se forman agrupando a otras instrucciones, ya sean elementales o compuestas, usando las reglas de secuencia, alternación (if) e iteración (while).

    Secuencia de instrucciones

    Un grupo de instrucciones escritas una a continuación de la otra se ejecutan en ese mismo orden:

    instrucción1;

    instrucción2;

    . . .

    También es posible agrupar las instrucciones entre llaves para que sean equivalentes a una sola instrucción:

    {

    instrucción1;

    instrucción2;

    . . .

    }

    Ejemplo:

    // Intercambiar dos valores a y b

    {

    int aux = a;

    a = b;

    b = aux;

    }

    Instrucciones condicionales

    Forma 1:

    if( condición )

    instrucción1;

    else

    instrucción2;

    Forma 2:

    if( condición )

    instrucción;

    Nota: No existe el "endif". Si lo que se desea ejecutar en cada caso es más de una instrucción, hay que escribirlas encerradas entre llaves.

    Ejemplo:

    // m = max(a,b)

    if( a>b )

    m = a;

    else

    m = b;

    Ejemplo:

    // a = abs(a)

    if( a<0 )

    a = -a; // se omite el "else" si es vacío

    Ejemplo:

    // Ordenar a, b (borrador)

    if( a>b )

    intercambiar a, b;

    La línea destacada no es una instrucción real del lenguaje, es sólo una forma de dejar pendiente esa parte del programa. Más adelante se podrá "refinar" esa seudo-instrucción definiendo:

    intercambiar a, b =>

    {

    int aux = a;

    a = b;

    b = aux;

    }

    Si se efectúa la sustitución del texto refinado en lugar del que se había escrito originalmente, resulta un texto de programa refinado que cumple con las reglas del lenguaje. Para ayudar a la auto-documentación del programa, se puede conservar la seudo-instrucción como comentario:

    // Ordenar a, b

    if( a>b )

    { // intercambiar a, b

    int aux = a;

    a = b;

    b = aux;

    }

    Ejemplo: Encontrar el máximo entre un conjunto de variables

    // m = max(a,b,c)

    // Solución 1 (borrador)

    if( a>b )

    m = max(a,c);

    else

    m = max(b,c);

    Realizando las sustituciones respectivas, se obtiene la siguiente versión "refinada":

    // m = max(a,b,c)

    // Solución 1 (versión refinada)

    if( a>b )

    {

    if( a>c )

    m = a;

    else

    m = c;

    }

    else

    {

    if( b>c )

    m = b;

    else

    m = c;

    }

    Nota: En este caso, las llaves no son realmente necesarias, pero pueden utiizarse si ayudan a la claridad del programa.

    Este enfoque de solución tiene la desventaja que es difícil de generalizar. Por ejemplo, el programa que encuentra el máximo de cuatro variables tiene aproximadamente el doble de líneas que éste, y por lo tanto el tamaño del programa va creciendo exponencialmente. Además no hay forma de escribir un programa para un número de variables que no sea conocido a priori.

    // m = max(a,b,c)

    // Solución 2 (borrador)

    m = a;

    m = max(m,b);

    m = max(m,c);

    Sustituyendo las seudo-instrucciones, resulta:

    // m = max(a,b,c)

    // Solución 2 (versión refinada)

    m = a;

    if( b>m )

    m = b;

    if( c>m )

    m = c;

    Con cada instrucción que se ejecuta, el estado del proceso cambia. Para entender lo que está sucediendo en el programa, puede resultar útil intercalar comentarios que describan lo que sabemos que se cumple después de cada instrucción:

    // m = max(a,b,c)

    // Solución 2 (versión refinada y con afirmaciones)

    m = a;

    // m == max(a)

    if( b>m )

    m = b;

    // m == max(a,b)

    if( c>m )

    m = c;

    // m == max(a,b,c)

    Ese tipo de comentarios se llaman afirmaciones (assertions), y en casos más complejos son fundamentales para entender lo que está sucediendo en un proceso.

    La generalización para encontrar el máximo de cuatro o más variables es directa, y en cada caso requiere sólo agregar dos líneas al programa. Más adelante se verá una versión para un número variable de datos.

    Instrucción iterativa

    La forma general es:

    while( condición )

    instrucción;

    La instrucción se ejecuta en forma reiterada mientras la condición sea verdadera.

    Cada vez que se intenta iniciar una nueva iteración (incluyendo la primera vez que ello ocurre) el programa se encuentra en un estado I llamado el invariante del ciclo.

    En general, al escribir un ciclo, se debe establecer la validez inicial del invariante, a través de una inicialización. El objetivo del ciclo es llegar a un estado final F. En cada iteración se debe, además, preservar la validez del invariante.

    Ejemplo:

    Considere el problema de encontrar el máximo de un número variable de datos, almacenados en un arreglo a[1], …, a[n]. Para verlo en forma iterativa, imagine un proceso en que los datos se van examinando uno a uno, comparándolos con el máximo encontrado hasta el momento. De esta manera, si en un instante dado ya se han examinado los datos hasta a[k], entonces se conoce el máximo hasta esa variable.

    // m = max(a[1],…,a[n]);

    k = 1;

    m = a[1];

    // m == max(a[1])

    // De esta manera trivial se incializa el siguiente invariante:

    // Invariante: k<=n && m == max(a[1],…,a[k])

    while( km )

    m = a[k];

    // k<=n && m == max(a[1],…,a[k])

    }

    // m = max(a[1],…,a[n])

    Esta última afirmación se deduce del hecho que al terminar el ciclo se sabe que el invariante sigue siendo verdadero, pero la condición del ciclo es falsa. En estricto rigor, la afirmación que podríamos hacer ahí es

    // k>=n && k<=n && m == max(a[1],,,a[k])

    de la cual se deduce la señalada al final del programa.

    ¿Cómo escribir un ciclo?

    • Encontrar un invariante adecuado. Para esto, a menudo es conveniente "relajar" la meta (estado final) al que se desea llegar. Por ejemplo, si se desea obtener:

    // m == max(a[1].,,,.a[n])

    se puede re-escribir esta condición separándola en dos condiciones que se puedan satisfacer independientemente:

    // m == max(a[1].,,,.a[k]) && k==n

    Esto, que puede parecer ocioso, es muy útil, porque a continuación se relaja la exigencia de esta condición, haciendo que se cumpla la primera parte, pero dejando que la segunda se satisfaga con "k<=n".

    • Escribir la inicialización, la cual debe asegurar que el invariante se cumpla antes de empezar a iterar.

    • Encontrar la condición de término. Esto se obtiene de comparar "qué le falta" al invariante para ser igual al estado final.

    • Escribir el cuerpo del ciclo, el cual debe:

    • conseguir que el proceso avance, de modo que termine algún día,

    • y preservar el invariante.

    Estos dos últimos objetivos suelen ser contrapuestos. Al efectuar un avance en el proceso, los valores de las variables cambian, con el resultado que a menudo se deja de satisfacer el invariante. Por lo tanto, el resto del cuerpo del ciclo se suele dedicar a tratar de recuperar la validez del invariante.

    Ejemplos de programas iterativos

    Algoritmos simples de ordenación

    Considere el problema de poner los elementos de un arreglo a[0],…,a[n-1] en orden ascendente.

    Se estudiarán varias soluciones, todas ellas consistentes en algoritmos sencillos, pero no muy eficientes. Estas distintas soluciones van a provenir de escoger distintos invariantes, o distintas maneras de preservarlos.

    Ordenación por inserción

    Este algoritmo va construyendo un trozo ordenado del arreglo al extremo izquierdo, y en cada iteración le agrega un nuevo elemento a ese grupo.

    Invariante:

    Monografias.com

    Esto es: los k primeros elementos ya están ordenados.

    // Ordenar a[0],…,a[n-1] por inserción (borrador)

    k = 0; // inicialmente no hay elementos ordenados (k=1 también funcionaría)

    while( k<n )

    {

    Insertar a[k] entre a[0],…,a[k-1];

    ++k;

    }

    Si la inserción se efectúa correctamente, es evidente que el programa anterior ordena correctamente al conjunto.

    El siguiente problema es ver cómo se realiza la inserción:

    Insertar a[k] entre a[0],…,a[k-1] =>

    for( j=k; j>0 && a[j-1]>a[j]; –j )

    {

    // intercambiar a[j-1] con a[j]

    t = a[j];

    a[j] = a[j-1];

    a[j-1] = t;

    }

    Al seguir el proceso de la inserción, se puede observar que la variable t toma siempre el mismo valor: el del elemento que se está insertando. Por lo tanto, se puede optimizar el programa realizando una única asignación a t antes de comenzar el ciclo. Otra observación es que la mayoría de las asignaciones a a[j-1] son inútiles, porque esa variable va a ser sobre-escrita en la iteración siguiente. Luego, se puede evitar esas asignaciones, reemplazándolas por una sola al final del ciclo:

    Insertar a[k] entre a[0],…,a[k-1] =>

    // versión optimizada

    t = a[k];

    for( j=k; j>0 && a[j-1]>t; –j )

    a[j] = a[j-1];

    a[j] = t;

    Efectuando la sustitución de esta versión, se obtiene la siguiente versión final para el algoritmo de ordenación:

    // Ordenar a[0],…,a[n-1] por inserción

    k = 0; // inicialmente no hay elementos ordenados (k=1 también funcionaría)

    while( k<n )

    {

    // Insertar a[k] entre a[0],…,a[k-1]

    t = a[k];

    for( j=k; j>0 && a[j-1]>t; –j )

    a[j] = a[j-1];

    a[j] = t;

    ++k;

    }

    El tiempo que demora este algoritmo en el peor caso es del orden de n2, lo que se denotará O(n2).Se puede demostrar que esto mismo es cierto si se considera el caso promedio.

    Ordenación por Selección

    Este algoritmo se basa en hacer pasadas sucesivas sobre los datos. En cada pasada, se encuentra el máximo del arreglo, y se lo lleva al extremo derecho. Una vez hecho esto, ese elemento deja de ser considerado, porque se encuentra ya en su posición definitiva. Esto conduce al siguiente invariante:

    Monografias.com

    En palabras: "Los elementos desde k hasta n-1 ya están ordenados y son mayores que los primeros k".

    // Ordenar a[0], …, a[n-1] por selección

    k = n; // inicialmente los n primeros están desordenados

    while( k>=2 )

    {

    Llevar el max de a[0], …, a[k-1] hacia a[k-1];

    –k;

    }

    Donde

    Llevar el max de a[0], …, a[k-1] hacia a[k-1] =>

    i = 0; // a[i] es el max hasta el momento

    for( j=1; j<=k-1; ++j )

    if( a[j]>a[i] )

    i = j;

    // ahora intercambiamos a[i] con a[k-1]

    t = a[i];

    a[i] = a[k-1];

    a[k-1] = t;

    El tiempo que demora este algoritmo es O(n2), y no hay diferencia entre el peor caso y el caso promedio.

    Más adelante se verá una forma diferente de realizar el proceso de encontrar el máximo, que permitirá que ese proceso sea más eficiente. Básicamente, se trata que al encontrar el máximo una vez, es posible obtener información adicional que facilite encontrar luego el segundo máximo, y así sucesivamente.

    Una forma de hacer esto es construir un torneo balanceado, al estilo de los torneos de tenis. Una vez que se han jugado todos los partidos del torneo, con n jugadores, si se desea encontrar al (verdadero) sub-campeón, basta con sustituir imaginariamente al campeón por un jugador pésimo, y jugar de nuevo los log n partidos en que estuvo involucrado el campeón. El resultado es un método de ordenación que demora tiempo O(n log n).

    Ordenación de la Burbuja

    Este método se basa en hacer pasadas de izquierda a derecha sobre los datos, intercambiando pares de elementos adyacentes que estén fuera de orden. Al final de cada pasada, en forma natural el máximo estará en la posición de más a la derecha (que es su posición final) y puede por lo tanto ser excluido en pasadas sucesivas.

    Esto conduce al siguiente invariante (idéntico al de ordenación por selección):

    Monografias.com

    El borrador del programa es:

    // Ordenar a[0], …, a[n-1] por la burbuja (borrador)

    k = n;

    while( k>1 )

    {

    Hacer una pasada sobre a[0], …, a[k-1];

    Disminuir k;

    }

    Donde

    Hacer una pasada sobre a[0], …, a[k-1] =>

    for( j=0; j<=k-2; ++j )

    if( a[j]>a[j+1] )

    { // Intercambiar a[j] con a[j+1]

    t = a[j];

    a[j] = a[j+1];

    a[j+1] = t;

    }

    y

    Disminuir k =>

    –k;

    Esto último puede parecer ocioso, pero pronto se verá que el expresarlo de esta manera da una flexibilidad que resulta útil.

    Un problema que presenta este programa es que si el archivo está incialmente ordenado, el programa igual hace n pasadas, cuando después de la primera ya podría haberse dado cuenta que el archivo ya estaba ordenado.

    Para aprovechar cualquier posible orden que pueda haber en el archivo, se puede hacer que el programa anote ("recuerde") el lugar en donde se produjo el último intercambio. Si la variable i se define de manera que el último intercambio en una pasada dada fue entre a[i-1] y a[i], entonces todos los elementos desde a[i] en adelante están ya ordenados (de lo contrario habría habido intercambios más hacia la derecha), y por lo tanto k se puede disminuir haciendo que sea igual a i:

    Hacer una pasada sobre a[0], …, a[k-1] =>

    i=0;

    for( j=0; j<=k-2; ++j )

    if( a[j]>a[j+1] )

    { // Intercambiar a[j] con a[j+1]

    t = a[j];

    a[j] = a[j+1];

    a[j+1] = t;

    //Recordar el lugar del último intercambio

    i = j+1;

    }

    Disminuir k =>

    k=i;

    El tiempo que demora este algoritmo tanto en el peor caso como en promedio es O(n2).

    Cálculo de xn

    Un algoritmo simple consiste en multiplicar n veces:

    // Algoritmo simple

    y = 1;

    for( j=n; j>0; –j )

    y = y*x;

    Este algoritmo evidentemente toma tiempo O(n), y su invariante se puede escribir como

    y * xj == xn

    Es posible encontrar un algoritmo sustancialmente más eficiente de la siguiente manera. Primero se desvinculan las dos ocurrencias de x en el invariante:

    y = 1;

    z = x;

    for( j=n; j>0; –j )

    y = y*z;

    con invariante

    y * zj == xn

    Esto podría parecer ocioso, pero permite hacer una optimización al observar que está permitido modificar la variable z al inicio del ciclo siempre que se mantenga la validez del invariante. En particular, si j resulta ser par, podemos elevar z al cuadrado si al mismo tiempo dividimos j por 2. De esta manera, el invariante sigue igual, pero j disminuye mucho más rápido.

    Mejor todavía, si esto se puede hacer una vez, entonces se puede hacer muchas veces siempre que j siga siendo par:

    y = 1;

    z = x;

    for( j=n; j>0; –j ) // Invariante: y * zj == xn

    {

    while( j es par )

    {

    z = z*z;

    j = j/2;

    }

    y = y*z;

    }

    La detección que j es par se puede implementar como

    j es par =>

    j&1 == 0

    Este algoritmo demora tiempo O(log n), lo cual se debe a que j sólo se puede dividir log n veces por 2 antes de llegar a 1. Es cierto que j sólo se divide cuando es par, pero si es impar en una iteración del for, está garantizado que será par a la siguiente.

    Diagramas de Estados

    Un diagrama de estados nos permite visualizar los diferentes estados por los que va pasando un programa. Las transiciones de un estado a otro se realizan ya sea incondicionalmente o bajo una condición. Además, pueden ir acompañadas de una acción que se realiza junto con la transición.

    Ejemplo: Contar palabras en una frase.

    Para simplificar, supongamos que la frase está almacenada en un string s, y supongamos que la frase termina con un punto. Por ejemplo,

    String s = "Este es un ejemplo.";

    Para los fines de este ejemplo, diremos que una "palabra" es cualquier secuencia de caracteres consecutivos distintos de blanco (y punto).

    Para resolver este problema, examinaremos los caracteres del string de izquierda a derecha, usando charAt(k), y lo que se haga con cada caracter depende si estábamos dentro o fuera de una palabra. Esto último correspnde al estado del programa.

    Monografias.com

    Veremos dos formas distintas de llevar este diagrama de transición a un programa. A pesar que ambos se ven muy distintos, en realidad representan exactamente el mismo proceso:

    // Version 1

    np = 0;

    estado = FUERA;

    for( k=0; (c=s.charAt(k))!='.'; ++k )

    {

    if( estado==FUERA )

    {

    if( c!=' ' )

    {

    ++np;

    estado = DENTRO;

    }

    }

    else // estado==DENTRO

    if( c==' ' )

    estado = FUERA;

    }

    // Version 2

    k = 0;

    np = 0;

    while( s.charAt(k)!='.' )

    { // estado==FUERA

    while( s.charAt(k)==' ' )

    ++k;

    if( s.charAt(k)=='.' )

    break;

    ++np;

    ++k;

    // estado==DENTRO

    while( s.charAt(k)!=' ' && s.charAt(k)!='.' )

    ++k;

    if( s.charAt(k)=='.' )

    break;

    ++k;

    }

    Problema

    Reordenar los elementos de a[0], …, a[n] dejando a la izquierda los <0 y a la derecha los >=0.

    Solución 1:

    Monografias.comInvariante:

    Monografias.com

    // Version 1

    i = 0;

    j = n;

    while( i<j )

    {

    if( a[i]<0 )

    ++i;

    else if( a[j]>=0 )

    –j;

    else

    {

    a[i] <-> a[j];

    ++i;

    –j;

    }

    }

             

    // Version 2

    i = 0;

    j = n;

    while( i<j )

    {

    while( i<j && a[i]<0 )

    ++i;

    while( i<j && a[j]>=0 )

    –j;

    if( i<j )

    {

    a[i] <-> a[j];

    ++i;

    –j;

    }

    }

    Solución 2:

    Invariante:

    Monografias.com

    Monografias.com

    i = 0;

    for( j=0; j<=n; ++j )

    if( a[j]<0 )

    {

    a[i] <-> a[j];

    ++i;

    }

    Recursividad

    Al programar en forma recursiva, buscamos dentro de un problema otro subproblema que posea su misma estructura.

    Ejemplo: Calcular xn.

    // Version 1

    public static float elevar( float x, int n )

    {

    if( n==0 )

    return 1;

    else

    return x * elevar(x, n-1);

    }

    // Version 2

    public static float elevar( float x, int n )

    {

    if( n==0 )

    return 1;

    else if( n es impar )

    return x * elevar( x, n-1 );

    else

    return elevar( x*x, n/2 );

    }

    Ejemplo: Torres de Hanoi.

    Monografias.com

    public class TorresDeHanoi

    {

    static void Hanoi( int n, int a, int b, int c )

    {

    if( n>0 )

    {

    Hanoi( n-1, a, c, b );

    System.out.println( a + " –> " + c );

    Hanoi( n-1, b, a, c );

    }

    public static void main( String[] args )

    {

    Hanoi( Integer.parseInt(args[0]), 1, 2, 3 );

    }

    }

    }

    "Dividir para reinar"

    Este es un método de diseño de algoritmos que se basa en subdividir el problema en sub-problemas, resolverlos recursivamente, y luego combinar las soluciones de los sub-problemas para construir la solución del problema original.

    Ejemplo: Multiplicación de Polinomios.

    Supongamos que tenemos dos polinomios con n coeficientes, o sea, de grado n-1:

    A(x) = a0+a1*x+ … +an-1*xn-1

    B(x) = b0+b1*x+ … +bn-1*xn-1

    representados por arreglos a[0], …, a[n-1] y b[0], …, b[n-1]. Queremos calcular los coeficientes del polinomio C(x) tal que C(x) = A(x)*B(x).

    Un algoritmo simple para calcular esto es:

    // Multiplicación de polinomios

    for( k=0; k<=2*n-2; ++k )

    c[k] = 0;

    for( i=0; i

    for( j=0; j

    c[i+j] += a[i]*b[j];

    Evidentemente, este algoritmo requiere tiempo O(n2). ¿Se puede hacer más rápido?

    Supongamos que n es par, y dividamos los polinomios en dos partes. Por ejemplo, si

    A(x) = 2 + 3*x – 6*x2 + x3

    entonces se puede reescribir como

    A(x) = (2+3*x) + (-6+x)*x2

    y en general

    A(x) = A'(x) + A"(x) * xn/2

    B(x) = B'(x) + B"(x) * xn/2

    Entonces

    C = (A' + A"*xn/2) * (B' + B"*xn/2)

    = A'*B' + (A'*B" + A"*B') * xn/2 + A"*B" * xn

    Esto se puede implementar con 4 multiplicaciones recursivas, cada una involucrando polinomios de la mitad del tamaño que el polinomio original. Si llamamos T(n) al número total de operaciones, éste obedece la ecuación de recurrencia

    T(n) = 4*T(n/2) + K*n

    donde K es alguna constante cuyo valor exacto no es importante.

    Teorema

    Las ecuaciones de la forma

    T(n) = p*T(n/q) + K*n

    con tienen solución

    T(n) = O(nlogq p) (p>q)

    T(n) = O(n) (p

    T(n) = O(n log n) (p=q)

    Por lo tanto la solución del problema planteado (p=4, q=2) es

    T(n) = O(nlog2 4) = O(n2)

    lo cual no mejora al algoritmo visto inicialmente.

    Pero… hay una forma más eficiente de calcular C(x). Si calculamos:

    D = (A'+A") * (B'+B")

    E = A'*B'

    F = A"*B"

    entonces

    C = E + (D-E-F)*xn/2 + F*xn

    Lo cual utiliza sólo 3 multiplicaciones recursivas, en lugar de 4. Esto implica que

    T(n) = O(nlog2 3) = O(n1.59)

    Recursividad y Tabulación (Programación Dinámica)

    A veces la simple recursividad no es eficiente.

    Ejemplo: Números de Fibonacci.

    Los números de Fibonacci se definen mediante la recurrencia

    fn = fn-1+fn-2 (n>=2)

    f0 = 0

    f1 = 1

    cuyos primeros valores son

    n  0 1 2 3 4 5 6 7 8 9 10 11 . . .

    fn 0 1 1 2 3 5 8 13 21 34 55 89 . . .

    Se puede demostrar que los números de Fibonacci crecen exponencialmente, como una función O(øn) donde ø=1.618….

    El problema que se desea resolver es calcular fn para un n dado.

    La definición de la recurrencia conduce inmediatamente a una solución recursiva:

    public static int F( int n )

    {

    if( n<= 1)

    return n;

    else

    return F(n-1)+F(n-2);

    }

    Lamentablemente, este método resulta muy ineficiente. En efecto, si llamamos T(n) al número de operaciones de suma ejecutadas para calcular fn, tenemos que

    T(0) = 0

    T(1) = 0

    T(n) = 1 + T(n-1) + T(n-2)

    La siguiente tabla mustra los valores de T(n) para valores pequeños de n:

    n 0 1 2 3 4 5 6 7 8 9 10 …

    T(n) 0 0 1 2 4 7 12 20 33 54 88 …

    Ejercicio: Demostrar que T(n) = fn+1-1.

    Por lo tanto, el tiempo que demora el cálculo de F(n) crece exponencialmente con n, lo cual hace que este método sea inútil excepto para valores muy pequeños de n.

    El origen de esta ineficiencia es que la recursividad calcula una y otra vez los mismos valores, porque no guarda memoria de haberlos calculado antes.

    Una forma de evitarlo es utilizar un arreglo auxiliar fib[], para anotar los valores ya calculados. Un método general es inicializar los elementos de fib con algún valor especial "nulo". Al llamar a F(n), primero se consulta el valor de fib[n]. Si éste no es "nulo", se retorna el valor almacenado en el arreglo. En caso contrario, se hace el cálculo recursivo y luego se anota en fib[n] el resultado, antes de retornarlo. De esta manera, se asegura que cada valor será calculado recursivamente sólo una vez.

    En casos particulares, es posible organizar el cálculo de los valores de modo de poder ir llenando el arreglo en un orden tal que, al llegar a fib[n], ya está garantizado que los valores que se necesitan (fib[n-1] y fib[n-2]) ya hayan sido llenados previamente. En este caso, esto es muy sencillo, y se logra simplemente llenando el arreglo en orden ascendente de subíndices:

    fib[0] = 0;

    fib[1] = 1;

    for( j=2; j<=n; ++j )

    fib[j] = fib[j-1]+fib[j-2];

    El tiempo total que esto demora es O(n).

    Esta idea se llama programación dinámica cuando se la utiliza para resolver problemas de optimización, y veremos algunas aplicaciones importantes de ella a lo largo del curso.

    ¿Es posible calcular fn más rápido que O(n)? Si bien podría parecer que para calcular fn sería necesario haber calculado todos los valores anteriores, esto no es cierto, y existe un método mucho más eficiente.

    Tenemos

    fn = fn-1+fn-2

    f0 = 0

    f1 = 1

    Esta es una ecuación de recurrencia de segundo orden, porque fn depende de los dos valores inmediatamente anteriores. Definamos una función auxiliar

    gn = fn-1

    Con esto, podemos re-escribir la ecuación para fn como un sistema de dos ecuaciones de primer orden:

    fn = fn-1+gn-1

    gn = fn-1

    f1 = 1

    g1 = 0

    Lo anterior se puede escribir como la ecuación vectorial

    fn = A*fn-1

    donde

    fn = [ fn ] A = [ 1 1 ]

      [ gn ] [ 1 0 ]

    con la condición inicial

    f1 = [ 1 ]

      [ 0 ]

    La solución de esta ecuación es

    fn = An-1*f1

    lo cual puede calcularse en tiempo O(log n) usando el método rápido de elevación a potencia visto anteriormente.

    Conceptos de Programación Orientada al Objeto (OOP)

    Un objeto combina datos y operaciones (métodos).

    El principio básico es el de encapsulamiento (ocultamiento de información). Esto permite separar el "qué" (especificación funcional, pública) del "cómo" (implementación, privada).

    Conceptos asociados a la programación orientada a objetos, para apoyar la reutilización de codigo:

    • Código genérico: la lógica de un algoritmo debe poder escribirse independientemente del tipo de los datos.

    • Herencia: permite extender la funcionalidad de un objeto.

    • Polimorfismo: permite que se seleccione automáticamente la operación apropiada según el tipo y número de los parámetros.

    Clases

    En Java un objeto es una instancia de una clase.

    Ejemplo:

    // Clase Entero, que permite leer y

    // guardar un valor en una variable entera

    public class Entero

    {

    // Datos privados

    private int valor;

    // Métodos públicos

    public int leer()

    {

    return valor;

    }

    public void guardar( int x )

    {

    valor = x;

    }

    }

    // Ejemplo de programa principal

    public class Prueba

    {

    public static void main( String[] args )

    {

    Entero m = new Entero();

    m.guardar( 5 );

    System.out.println( "m=" + m.leer() );

    }

    }

    Tipos de métodos
    Constructores

    Permiten inicializar el objeto. Puede haber varios constructores con distinto número y tipos de parámetros.

    Si no hay un constructor definido, los campos se inicializan automáticamente con valores nulos.

    El constructor debe tener el mismo nombre que la clase.

    Ejemplo: Clase para almacenar fechas:

    public class Fecha

    {

    private int a;

    private int m;

    private int d;

    // Constructor con parámetros

    public Fecha( int aa, int mm, int dd )

    {

    a = aa;

    m = mm;

    d = dd;

    }

    // Constructor sin parámetros

    public Fecha()

    {

    a = 2001;

    m = 1;

    d = 1;

    }

    }

    Ejemplos de uso:

    Fecha f1 = new Fecha();

    Fecha f2 = new Fecha( 2001, 4, 11 );

    "Mutators" y "accessors"

    Las variables de una clase tipicamente son privadas. Para mirar su valor, o para modificarlo, hay que utilizar métodos ad hoc (como leer y guardar en el ejemplo de la clase Entero).

    Esto es un mayor grado de burocracia, pero aisla a los usuarios de una clase de los detalles de implementación de ella, y evita que se vean afectados por eventuales cambios en dicha implementación.

    toString

    Al imprimir un objeto a usando println, automáticamente se invoca a

    a.toString()

    para convertirlo a una forma imprimible. Esto mismo ocurre cada vez que se utiliza a en un contexto de String.

    En el ejemplo, si vamos a imprimir objetos de tipo Fecha, debemos proveer una implementación de toString dentro de esa clase:

    public String toString()

    {

    return d + "/" + m + "/" + a;

    }

    equals

    El método equals se utiliza para ver si dos objetos tienen el mismo valor. Se invoca

    if( x.equals(y) )

    y se declara como

    public boolean equals( Object b )

    El tipo Object usado aquí es un tipo de objeto "universal" del cual se derivan todos los otros. El siguiente ejemplo muestra una implementación de equals para la clase Fecha:

    public boolean equals( Object b )

    {

    if( !(b instanceof Fecha) )

    return false; // el otro objeto no era de tipo Fecha

    Fecha f = (Fecha) b; // para verlo como una Fecha

    return a==f.a && m==f.m && d==f.d;

    }

    this

    La referencia this identifica al objeto actual. Permite desde dentro de la clase accesar los campos propios diciendo, por ejemplo, this.a. Esto en realidad es redundante, porque significa lo mismo que decir simplemente a, pero puede ser más claro en la lectura.

    También permite comparar si este objeto es el mismo que otro (no sólo si tienen el mismo contenido, sino si ambas referencias apuntan al mismo objeto).

    El otro uso de this es como constructor, para llamar a otro constructor de la misma clase. Por ejemplo, el constructor sin parámetros de la clase Fecha podría haber sido declarado como:

    public Fecha()

    {

    this( 2001, 1, 1 );

    }

    Campos estáticos

    Hay dos tipos de campos estáticos:

    public final static double PI = 3.14159; // constante

    private static int precioActual = 1300; // variable compartida

    // por todos los objetos de esa clase

    Métodos estáticos

    Los métodos estáticos están asociados a una clase, no a objetos particulares dentro de ella.

    Ejemplos:

    Math.sin

    Integer.parseInt

    Los métodos estáticos no pueden hacer uso de la referencia this.

    Packages

    Las clases se pueden agrupar en "paquetes". Para esto, cada clase debe precederse de

    package P;

    class C

    {

    . . .

    }

    Cuando a una variable no se le pone public ni private, es visible sólo dentro del mismo package.

    La clase C se denomina P.C, pero si antes decimos import P.C; o bien import P.*;, entonces podemos referirnos a la clase simplemente como C;

    Herencia

    Principio que permite reutilizar trabajo ya hecho. Se basa en la relación is-a.

    Ejemplo:

    Círculo is-a FiguraAuto is-a Vehículo

    Las clases forman una jerarquía en base a la relación de herencia.

    Otro tipo de relación distinta es has-a. Por ejemplo:

    Auto has-a Manubrio

    Este tipo de relación se llama agregación y a menudo es más importante que la herencia.

    Clase base:

    La clase de la cual se derivan otras

    Clase derivada:

    Hereda todas las propiedades de la clase base. Luego puede agregar campos y métodos, o redefinir métodos.

    Los cambios que se hagan en la clase derivada no afectan a la clase base.

    Sintaxis:

    public class Derivada extends Base

    {

    . . .

    }

    • Los campos adicionales generalmente son privados.

    • Los métodos de la clase base que no se redefinen en la clase derivada se heredan sin cambio, excepto por el constructor.

    • Los métodos que se redefinen tienen prioridad.

    • Se pueden agregar nuevos métodos.

    • Los métodos públicos se pueden redefinir como privados.

    Visibilidad

    Los campos privados de la clase base no se ven desde las clases derivadas. Para que un campo de este tipo sea visible debe declararse como protected. Esta posibilidad debe usarse con mucho cuidado.

    Constructores

    Cada clase define su propio constructor, el cual lleva el mismo nombre que la clase.

    Si no se define un constructor, se genera automáticamente un constructor sin parámetros que:

    • llama al constructor con cero parámetros de la clase base, para la parte heredada, y luego

    • inicializa con valores nulos los campos restantes.

    Si se escribe un constructor, su primera instrucción puede ser una llamada a

    super( … );

    lo cual invoca al constructor de la clase base.

    final

    Si un método se declara como final, entonces no puede ser redefinido en las clases derivadas.

    Análogamente, una clase declarada como final no puede ser extendida.

    Métodos abstractos

    Un método abstracto declara funcionalidad, pero no la implementa. Las clases derivadas deben proveer implementaciones para estos métodos.

    Una clase abstracta es una clase que tiene al menos un método abstracto. No se puede crear objetos pertenecientes a una clase abstracta (no se puede ejecutar new).

    Ejemplo:

    abstract class Figura

    {

    private String nombre;

    abstract public double area();

    public Figura( String n )

    {

    nombre = n;

    }

    // Este constructor no puede ser invocado directamente,

    // sólo lo usan las clases derivadas

    final public double compArea( Figura b )

    {

    return area() – b.area();

    }

    Partes: 1, 2, 3, 4

    Página siguiente 

    Nota al lector: es posible que esta página no contenga todos los componentes del trabajo original (pies de página, avanzadas formulas matemáticas, esquemas o tablas complejas, etc.). Recuerde que para ver el trabajo en su versión original completa, puede descargarlo desde el menú superior.

    Todos los documentos disponibles en este sitio expresan los puntos de vista de sus respectivos autores y no de Monografias.com. El objetivo de Monografias.com es poner el conocimiento a disposición de toda su comunidad. Queda bajo la responsabilidad de cada lector el eventual uso que se le de a esta información. Asimismo, es obligatoria la cita del autor del contenido y de Monografias.com como fuentes de información.

    Categorias
    Newsletter