Está en la página 1de 66

Ficha 9

Subproblemas y funciones
En la práctica los problemas que se enfrentan son de estructura compleja, osea que un problema se puede dividir
en subproblemas (problemas simples).
A la hora de plantear un algoritmo para la resolución de estos problemas simples estos subproblemas reciben
datos, desarrollan procesos y generan salidas.
Esta forma de centrarse en la resolución de subproblemas es una técnica o paradigma de programación llamada
“Programación Estructurada”.

Los datos del problema compuesto serán tomados como datos por el subproblema para ser procesados y arrojar
ciertos resultados parciales que serán entregados como datos para ser tomados en el siguiente subproblema y
así llegar al resultado final.

Subrutinas: introducción y conceptos generales


En Python para representar una subrutina (subproblema) se hace a través de Funciones.
Una subrutina (o subproceso) es un segmento de un programa que se escribe en forma separada del programa
principal. A cada subrutina el programador asigna un nombre o identificador y mediante este la subrutina puede
ser activada todas las veces que sea necesario.
En un diagrama de flujo la subrutina es representada por un hexágono y para dar final al problema principal se
hace con una línea horizontal.

Funciones en Python - Parámetros y retorno de valores


En Python una función (subrutina) es un segmento de programa que se codifica en forma separada con un
nombre asignado para poder activarla, esta consta de dos partes:
Cabecera o encabezado donde se indica el nombre de la misma seguida de paréntesis dentro de estos van
variables llamadas Parámetros que son los datos que la función va a procesar.
Bloque de acciones es la sección donde indican los procesos que lleva acabo la función. Este comienza en el
reglón seguido a la cabecera y va identado a la derecha. Y contiene las instrucciones que la función va a ejecutar
cuando se la invoque desde otra parte del programa. Además, es típico que el bloque de la función finalice con
una instrucción de retorno de valor llamada return.
Parámetros

Cabecera o def menor(n1, n2, n3):


encabezado if n1 < n2 and n1 < n3:
mn = n1
else:
if n2 < n3: Bloque de acciones
mn = n2
else:
mn = n3
return mn

Instrucción de retorno
de valor

Una función puede entenderse como un proceso separado (también designado como caja negra debido a que esos
procesos permanecen ocultos para quien usa la función desde el exterior) que acepta entradas o datos (los
parámetros), los procesa para obtener uno o más resultados o salidas, y devuelve esos resultados mediante la
instrucción return.
Las funciones deben ubicarse antes del script desde donde se hacen las invocaciones a ellas: el interprete
lanzará un error si se intenta llamar a la función cuya definición este mas adelante (mas abajo) desde el script
en el código fuente.
Cuando se dice que una función es invocada o llamada significa que se activa para que en ese momento se
ejecuten las instrucciones que contiene en su bloque de acciones. Esta recibe los datos que se quieren procesar
a través de los parámetros (variables o valores) y los asigna a las variables que tienen declarada dentro de ella.
Como estas variables contienen ahora una copia de los valores que fueron enviados desde el script principal, la
función puede operar con ellas y obtener el resultado esperado.
Para que esos resultados sean entregados al punto desde el cual se llamó a la función, se usa la instrucción
return.
El proceso de toma de parámetros y retorno de valor ocurre de forma automática en Python que copia los
valores de las variables enviadas en las variables declaradas en la cabecera y luego Python controla el retorno
de resultado con la instrucción return.

Paso de parámetros a una función


Un parámetro es una variable cuyo valor se envía a una función para que esta lo procese. El uso de parámetros
permite que una misma función pueda ser usada en forma generalizada sobre variables diferentes que entran
como datos, dicha función hace caso omiso del nombre del parámetro que recibe y se queda con una copia del
valor del mismo para procesarlo. Pero para que la función pueda tomar esa copia, debe asignar dicho valor en
alguna otra variable que esté dentro de la función pues de otro modo lo perdería y no podría procesarlo.
Parámetros Formales
def menor(n1, n2, n3):
if n1 < n2 and n1 < n3:
mn = n1
else:
if n2 < n3:
mn = n2
else:
mn = n3
return mn
# invocación a la función...
a, b, c, = 3, 6, 2
men = menor(a, b, c) Parámetros Actuales

Una vez que cada parámetro actual quedó asignado en su parámetro formal correspondiente, la función
comienza a ejecutar su bloque de acciones. No importa cómo se llamen las variables en el momento de llamar a
la función. La función reemplaza esos nombres por los nombres de sus propios parámetros, y se evita con esto
tener que escribir el mismo código varias veces, cambiando el nombre de las variables.

Regla general de parámetros:


• Si en la cabecera de una función están declarados n parámetros formales, entonces al invocar a esa
función deben enviarse n parámetros actuales.

• El tipo de valor contenido en los parámetros actuales debería coincidir con el tipo de valor que el
programador de la función esperaba para cada uno de los parámetros formales, tomados en orden de
aparición de izquierda a derecha, para evitar problemas en tiempo de ejecución al intentar procesar
valores de tipos incorrectos.

• Al declarar la función debe indicarse la lista de parámetros formales de la misma, simplemente


escribiendo sus nombres separados por comas. Los nombres que se designen para estos parámetros
formales pueden ser cualesquiera.

• En el bloque de acciones de la función, deben usarse los parámetros formales. El nombre o identificador
de los actuales no tiene importancia alguna dentro de la función, ya que sus valores fueron copiados en
los formales

• Si dentro del bloque de acciones de la función se altera el valor de un parámetro formal, este cambio no
afectará al valor del parámetro actual asociado con él. Al invocar a la función, se crean los parámetros
formales como variables separadas, distintas e independientes, y automáticamente se copian en ellas los
valores de los parámetros actuales respectivos (este esquema se conoce como parametrización por valor
o parametrización por copia). Una función parametrizada es más fácil de controlar en cuanto a posibles
errores lógicos, ya que las variables que usa están definidas dentro de ella y sólo existen y pueden
usarse dentro de ella. Finalmente, una función parametrizada puede volver a utilizarse en forma natural
en el mismo programa o en otros, sin tener que hacer modificaciones especiales, haciendo así más
cómodo, rápido y eficiente el trabajo del programador.
Mecanismo de retorno de valores en una función en Python
Una función también se puede declarar de forma que no retorne valor alguno sino que simplemente desarrolle
una acción y termine.
Las dos clases de funciones que en general existen: las funciones con retorno de valor y las funciones sin retorno
de valor:

Funciones con retorno de valor Funciones sin retorno de valor


Son aquellas que devuelven un valor como resultado Son aquellas que realizan alguna acción, pero no se
de la acción que realizan, de forma tal que ese valor espera que retornen valor alguno como resultado de
puede volver a usarse en alguna otra operación. la misma. Una función sin retorno no es utilizada de la
misma menara que las con retorno de valor.

Implementación y uso de funciones que retornan valores en Python


Las funciones con retorno de valores deben incluir en alguna parte de su bloque la instrucción return indicando
en la misma el valor que debe retornar. Esta instrucción es cancelativa y se debe tener cuidado en donde se
coloca, al ejecutar esta instrucción, la ejecución de la función que la contiene se da por finalizada aun cuando no
se haya llegado al final de la misma.

Cualquier instrucción que sea colocada debajo de ella en la misma rama lógica en una función no será ejecutada
(aunque Python no informara error)
En Python una función puede retornar dos o más valores si fuese necesario, en forma de tupla,
Además, en Python, una función puede contener la definición de una o más funciones dentro de ella (y estas
"subfunciones" se conocen como funciones locales) esto quiere decir que cuando una función es local a otra
función esta solo existe dentro del ámbito de la función que la contiene.

Implementación y uso de funciones sin retorno de valor en Python


Estas funciones al ser invocadas realizan una acción, pero no se espera que retornen ningún valor al terminar.
O sea que en el bloque de acciones no lleva la instrucción return.
Si una funcion sin retorno esta bien planteada se la invoca solamente escribiendo su nombre seguido de
paréntesis y pasandoles los parámetros necesarios.

Tambien se puede hacer que una función incluya un return sólo para forzar su terminación.

Cualquier función en Python puede invocarse como si retornase un valor aun cuando no se incluya el return
explicito en su bloque de acciones o si alguna de sus ramas lógicas no lo incluyese, o si se activa un return sin
asociarle un valor en estos casos la función retorna un None.
Salida

Python asume que si una función f() es invocada en un contexto en el cual se esperaría que retorne un valor,
asumirá que el valor retornado es None si f() en realidad no tuviese previsto un valor a devolver.
La instrucción pass también puede usarse para dejar un vacío en el bloque de acciones en el caso que el
programador no tenga definido que hacer con esa función.

Variables locales y variables globales.


En Python cualquier variable que se inicialice dentro del bloque de acción se dice que es local a la función
(variable local) por esto, en toda función define un espacio de nombres (o namespace): un bloque de código en el
cual las variables que se definen pertenecen a ese espacio y no son visibles ni utilizables desde fuera de él.
Cualquier intento de usar una variable fuera de su ámbito provocaría un error de interprete por uso de variable
no definida. Se dice ámbito a la región de un programa donde es reconocida y utilizada una variable si las
variables no están encerradas en de un bloque dentro de una función estas pueden ser utilizadas en cualquier
parte del programa son variables globales o que están dentro del ámbito global.
Los parámetros formales de una función, son también variables locales a esa función, aunque con una pequeña
diferencia: los parámetros formales son variables locales que se inicializan en forma automática al ser invocada
la función, asignando en ellos los valores que se hayan enviado como parámetros actuales.
En cambio, una variable local común debe ser inicializada en forma explícita dentro del bloque de la función con
una asignación.
Toda otra variable que se use en la función, pero no sea ni dato ni resultado debería dejarse como local,
inaccesible desde el exterior.
En Python existe una forma adicional de hacer que una variable definida dentro de una función sea visible desde
fuera de la misma y consiste en usar la palabra reservada global para avisarle al intérprete que las variables
enumeradas con ella deben ser tomadas como globales y no como locales
La declaración global f, i hace que el intérprete no encierre a esas variables en el ámbito local de la función, sino
que las hace escapar hacia el ámbito global.
Como se ve, el sólo hecho de definir variables en el script principal no garantiza que las mismas serán utilizables
en forma compartida entre todas las funciones que implemente el programador. Si cualquier función asigna un
valor en una variable cuyo identificador ya existía en un ámbito global, entonces estará creando una variable
local nueva, con el mismo nombre que la global, y la local siempre tendrá preferencia de uso sobre la global Se
dice en estos casos, que la variable local está ocultando a la global (y por lo tanto, impidiendo su uso en el
ámbito de la función). La manera de evitar esto y darle preferencia a la global por sobre la local, es usar la
declaración global ya citada. El uso de variables globales puede a veces parecer simple y directo, pero al mismo
tiempo abre la puerta a potenciales problemas: como las variables globales son de uso compartido, cualquier
función puede cambiar sus valores y estos cambios pueden afectar la lógica de funcionamiento en el resto de las
funciones.

Parametro “end”

Al invocar a print() se ha usado un parámetro adicional llamado end, asignado con un espacio en blanco: Ese
parámetro forma parte de un esquema de parametrización especial en Python designado como parametrización
por palabra clave para indicar el final de la línea mostrada por print(). Por defecto, el valor de end es un caracter
"\n" (salto de línea), lo cual explica por qué las líneas mostradas con print aparecen siempre a renglón seguido.
Si se asigna en end un espacio en blanco, entonces print() cambiará el salto de línea por el blanco, y las salidas
mostradas aparecerán en la misma línea de la consola.
Ficha 10
Programación Modular
Existen dos motivos por el cual se usan funciones en un programa en cualquier lenguaje de programación:
• El primer motivo es lograr ahorro de líneas de código, desarrollando como función a todo bloque de
instrucciones que en un mismo programa se use en muchas ocasiones.

• El segundo motivo es el de permitir que un programador pueda modularizar adecuadamente sus


programas, dividiéndolos en subproblemas que le resulten más fáciles de manejar y controlar. En este
sentido, el uso de funciones no se limita sólo a evitar posibles redundancias de código, sino que también
apunta a la mejor estructuración de un programa.

Un paradigma de programación es un conjunto de reglas, convenciones y prácticas para desenvolverse con éxito
en la tarea de la programación de computadoras. Unos de esos paradigmas es el que se conoce como
programación estructurada.
El Paradigma de la Programación Estructurada se centra en diversas ideas para producir programas bien
estructurados, y en la medida de lo posible simples de comprender y de mantener.
Uno de los principios de la Programación Estructurada se conoce como Programación Modular y es el de
descomponer la solución a un problema en subproblemas más simples, resolverlos por separado (mediante
funciones) y luego unir las piezas en el programa completo final. En ese sentido, se suelen designar como
módulos a las subrutinas o conjuntos de subrutinas que se plantean para resolver el problema
La Programación Modular no es el único principio de la PE: existen otros, como evitar el uso de instrucciones de
interrupción (break, continue, exit(), etc.), o el de mantener un único punto de inicio y un único punto de fin en un
programa.

Funciones generalizadas (o reutilizables)


El reutilizar una funcion depende de 2 factores:
• La función no debería presentar dependencia externa: los procesos de la función no deberían depender
de variables declaradas fuera de ella. Es lo que generalmente ocurre si la función depende de que
existan previamente ciertas variables globales: si esas variables no se crearon fuera de la función al
invocarla, se provocará un error.
• La función no debería presentar dependencia de la interfaz de usuario: si la función lleva a cabo carga
por teclado o visualización por consola estándar, entonces llamar a esa función obliga a que los datos o
resultados sean tomados desde el teclado o enviados a la pantalla, y eso es muy limitante si los datos ya
se tenían cargados, o se quería hacer un cálculo con el resultado (pero no mostrarlo...)
El problema de la dependencia de una función o módulo de software respecto de la interfaz de usuario empleada,
también se conoce como acoplamiento entre procesos e interfaz de usuario.

Obviamente, no siempre se necesitará diseñar funciones o módulos genéricos y reusables. En algún momento
durante el desarrollo de un programa casi seguro deberá incluir una o mas funciones que interactúen con el
usuario mediante la interfaz.

Elementos de cálculo combinatorio.


El cálculo combinatorio (o también análisis combinatorio) es una rama de las matemáticas (y en particular de la
matemática discreta) cuyo objeto general es el estudio de las posibilidades en que pueden combinarse los n elementos de
un conjunto, agrupándolos de maneras diferentes (ya sea en grupos de m elementos con m < n, o en grupos de exactamente
n elementos, etc.) Un elemento básico del cálculo combinatorio es el llamado Principio Fundamental de Conteo (o Principio
Básico de Conteo) que expresa cuántas resultados posibles existen de combinar k eventos.

Otra necesidad común del análisis combinatorio consiste en determinar en cuántas formas diferentes pueden arreglarse o
combinarse los n elementos de un conjunto, tomados todos a la vez, pero de forma que cada uno aparezca una sola vez en
cada arreglo (no valen repeticiones), y de forma que el orden importe: dos variaciones con los mismos elementos pero en
diferente orden, se consideran distintas. Esto se conoce como el número de permutaciones de n elementos, tomados de a n,
y podemos denotarlo como p(n, n).

En general, la idea de permutación implica que el orden en que se presentan los elementos es relevante.

Las permutaciones tienen en cuenta el orden final de cada variación de elementos. Pero en muchos casos, ese orden no es
relevante y dos variaciones de los mismos elementos se deben tomar como iguales a los efectos del conteo. Si ese es el
caso, se habla de combinaciones en lugar de permutaciones.
Ficha 11
Módulos y Paquetes
El mecanismo de parametrización requiere que una función al ser invocada se envien exactamente tantos
parámetros actuales como sea la cantidad de parámetros formales que tenga definidos en su cabecera.
y = pow(n, 2)
pero también en Python una función puede recibir y manejar un numero variable de parámetros ej:
funciones predefinidas como max() y min():

Para lograr que una función acepte un número variable de parámetros actuales al ser invocada, Python provee
tres mecanismos:
Parametrizacion con valores por defecto:
A una funcion se le pueden asignar ciertos parámetros formales y al invocarla se le puede enviar o no menos
parámetros actuales.
Un parámetro formal tiene un valor por defecto, cuando el mismo es asignado en forma explícita con un valor en
el momento en que
se define en la cabecera de la función.

El tercer parámetro actual puede


obviarse al invocar a la función (como en
ordenar(a, b)) y su valor se asumirá True.

Los parámetros formales que no tienen valor por defecto en la función, se designan como parámetros
posicionales; y: si una función tiene parámetros posicionales, entonces al invocar a la función es obligatorio
enviar los parámetros actuales que correspondan a esos posicionales, pues de lo contrario se lanzará un error
de intérprete. Además, al declarar una función, los parámetros posicionales deben definirse siempre antes que
los parámetros con valor default.

Esto no producirá un error de interprete pero los parámetros actuales que les son asignados a los parámetros
posicionales de la función al ser invocada asumen la posición incorrecta el valor de a se le asigna el valor de n1 y
el valor de False se le asigna el valor de n2 y el 3er parámetro ascendent sigue valiendo True lo cual la función
procesara valores incorrectos.
Parametrización con palabra clave:
Si una función tiene varios parámetros con valores por defecto, podría producirse ambigüedad al invocarla.

Los parámetros con valores default deben declararse después que los parámetros posicionales, y además no
pueden saltearse en forma directa cuando la función es invocada. Todos los parámetros actuales que se envíen a
la función, serán tomados y asignados a los parámetros formales en estricto orden de aparición de izquierda a
derecha, pudiendo provocar ambigüedades si se intenta saltear un parámetro.
Python usa tanto el mecanismo de parámetros con valores default como este mecanismo de selección de
parámetros por palabra clave en sus funciones predefinidas de la librería estándar ej: print()
Esta función (entre otros) dispone de dos parámetros formales con valores default que son seleccionados por
palabra clave: sep y end. El primero se usa para indicar a la función qué caracter o cadena de caracteres debe
usar para separar las cadenas que se quieren mostrar, y su valor default es un espacio en blanco (' '). El segundo
se usa para indicar con qué caracter o cadena de caracteres debe terminar la visualización, y su valor default es
un salto de línea ('\n').
Se pueden cambiar esos caracteres llamando a la función y seleccionando esos parámetros por su palabra clave:

Salida…

si la función print() es invocada sin ningún parámetro su efecto será simplemente mostrar el valor de end,
provocando sólo un salto de línea.
Salida…

Parametrización por lista de longitud arbitaria:


Se puede hacer que una función acepte un número arbitrario de parámetros al ser invocada. Los parámetros que
lleguen de esta forma, lo harán en forma de tupla.
La forma de hacerlo consiste en colocar un asterisco delante del último parámetro posicional que la función
haya definido. Este parámetro representa la tupla que recibirá a los parámetros extra que se envíen
Salida…

Definición y uso de módulos en Python


Un módulo es una colección de definiciones (variables, funciones, etc.) contenida en un archivo separado con
extensión .py que puede ser importado desde un script o desde otros módulos para favorecer la reutilización de
código y/o el mantenimiento de un gran sistema. Se trata de lo que en otros lenguajes llamaríamos una librería
externa. Además, un conjunto de módulos en Python puede organizarse en carpetas llamadas paquetes (o
packages) que favorezcan aún más el mantenimiento y la distribución.
La idea de usar un módulo, es agrupar definiciones de uso común (funciones genéricas, por ejemplo) en un
archivo separado, cuyo contenido pueda ser accedido cuando se requiera, sin tener que repetir el código fuente
de cada función o declaración en cada programa que se desarrolle.
Para crear un módulo, sólo hay que crear dentro del proyecto un nuevo archivo Python (extensión .py) y escribir
dentro de él (por ejemplo) las funciones que vaya a contener, una debajo de la otra.
Para que un script o un programa pueda usar las funciones y demás declaraciones de un módulo externo, debe
importarlo usando alguna de las variantes de la instrucción import.
La instrucción import soporte que se muestra al inicio de este programa introduce el nombre del módulo en el
contexto del programa, pero no el nombre de las funciones definidas en él. Por eso, para invocar a una de sus
funciones debe usarse el operador punto (.) en la forma: nombre del módulo + punto + función a invocar:

También que es posible asociar una función a un identificador local (que en realidad es una referencia o un
puntero a esa función), a modo de sinónimo que simplifique el uso:

Se puede usar la variante from - import de modo que se incluya el nombre de la función específica que se quiere
acceder desde un módulo, o de varias de ellas separadas por comas, evitando así tener que usar el operador
punto cada vez que se quiera invocarla:

También puede usar un asterisco (*) en lugar del nombre de una función particular, para lograr así el acceso a
todas las funciones del módulo sin usar el operador punto.

Todo módulo incluye una tabla de símbolos interna, que contiene los nombres de todas las variables y funciones
que se definieron en ese módulo. Esa tabla se carga en memoria en el momento en que el módulo es accedido
con una directiva import.
Además de las variables que define el programador, un módulo incluye algunas variables globales especiales
definidas en forma automática. Ej: la variable __author__ que se usa para contener el nombre del programador o
equipo que desarrolló el programa.
El identificador de una variable especial se forma con dos guiones bajos antes y después del nombre de la
variable. Otra variable especial de uso muy común, es __name__, que es asignada automáticamente con el
nombre del módulo.

Salida…

Cualquier archivo de código fuente en Python (extensión .py) es un módulo. Si contiene un script principal, ese
módulo se dice ejecutable (porque al ejecutarse se lanzará el script principal). Pero si NO contiene un script, al
ejecutarlo no ocurrirá nada y se mostrará el mensaje:
Si un módulo ejecutable (contiene un script) es importado por otro, al momento de la importación el módulo se
cargará en memoria y se ejecutará el script del módulo.
Para evitar eso, hay que saber que la variable __name__ de un módulo se inicializa en forma automática, pero el
valor inicial depende de cómo fue cargado el módulo:

Por lo tanto, se puede controlar si un módulo fue importado o ejecutado agregando esta condición para su script
principal:

if __name__ == "__main__":
# se pidió ejecutar ESTE modulo... poner aquí el script principal...

Si el módulo NO TIENE un script principal, claramente no es necesario incluir esta condición en ese módulo.
Si lo tiene debería tener esta condición para controlar la ejecución de ese script en el caso de que el módulo
llegase a ser importado desde otro módulo externo.
Qué reglas sigue el intérprete cuando encuentra una instrucción import nombre_modulo para encontrar el
módulo requerido:
1. Lo primero que analiza el intérprete, es si existe algún módulo interno con ese nombre.
2. Si no lo encuentra así, busca el nombre del módulo en una lista de carpetas dada por la variable
sys.path(es decir, la variable path contenida en el módulo interno sys). Esta variable es automáticamente
inicializada con los siguientes valores:
• El nombre del directorio actual (donde está el script o programa que realizó el import)
• El valor de la variable de entorno PYTHONPATH (que se usa en forma similar a la variable
• PATH del sistema operativo)
• Y otros valores default que dependen de la instalación Python.
La variable sys.path contiene una cadena de caracteres con la información enumerada en el párrafo anterior
asignada por default. Sin embargo, el programador puede modificar esa variable usando funciones directas de
manejo de cadenas, como la función append() que permite añadir una cadena a otra.

Definición de paquetes en Python


Un paquete (o package) es una forma de organizar carpetas que contienen módulos en Python, de forma que
luego cada módulo del package puede accederse con la ruta del package y el nombre del módulo, usando el
operador punto para separar cada parte.
Por ejemplo, si se tiene un package llamado modulos, y dentro de esa carpeta hay un módulo llamado
funciones.py, entonces el nombre completo del módulo es modulos.funciones, y un import para accederlo sería:

import modulos.funciones
El uso de packages permite organizar mejor un gran proyecto, y también ayuda a evitar ambigüedades por
conflictos de nombres (por caso, programadores que aportan módulos diferentes, pero que contienen funciones
con el mismo nombre)
Para que Python reconozca la designación de un paquete mediante el operador punto y la traduzca desde una
estructura de carpetas física, esas carpetas deben incluir un archivo fuente Python llamado __init__.py, el cual
puede ser tan sencillo como un archivo vacío, o puede incluir scripts de inicialización para el paquete, o asignar
valores iniciales a ciertas variables globales.
En PyCharm, para crear un paquete de módulos dentro de un proyecto, ir a crear un nuevo proyecto (en lugar de
un archivo fuente) y dentro de ese proyecto incluya un paquete: apunte al proyecto con el mouse, haga click
derecho, seleccione la opción New en el menú emergente que aparece, y luego seleccione el ítem "Python
Package".
Dentro del proyecto se creará una carpeta con el nombre a (elección del programador) para el paquete, y
contendrá su correspondiente archivo __init__.py (posiblemente vacío o solo conteniendo alguna inicialización de
alguna variable global, tal como la variable __author__ asignada con el nombre del usuario o creador del
paquete). Luego de creado el paquete, puede crear subpaquetes dentro de él, repitiendo el procedimiento
marcado más arriba, pero ahora apuntando con el mouse al paquete dentro del cual desee crear un subpaquete.

Cada carpeta o subcarpeta que forma parte de la estructura del paquete se considera ella misma como un
paquete o un subpaquete, pero debe tener para ello su propio archivo __init__.py
Para acceder a módulos que han sido almacenados en paquetes o subpaquetes, se puede usar la instrucción
import o from - import, pero ahora escribiendo la ruta completa de paquetes y subpaquetes delante del nombre
del módulo que se quiere acceder, separando los nombres de las carpetas con el operador punto:

También se puede usar from – import e incluir un asterisco (*) para garantizar que el nombre de ese paquete sea
tomado en forma implícita en todos sus elementos. Una instrucción como:

Pero si no se quiere acceder a todos los subpaquetes que pueda contener el paquete se puede recurrir al archivo
__init__.py del paquete cuyo contenido se quiere controlar, y asignar en la variable __all__ una lista de cadenas de
caracteres con los nombres de los módulos que serán importados con un from – import *

Cadenas de documentación (docstrings) en Python


En el desarrollo de un sistema complejo es importante generar documentación técnica de ayuda para cada uno
de los componentes del proyecto (funciones, módulos, paquetes, etc.) Esa documentación es imprescindible como
manual técnico para otros programadores.
Muchos IDEs usan documentación técnica para ayudar al programador por si le surge alguna duda. En PyCharm,
puede apuntar con el mouse a una función cualquiera y pulsar la combinación de teclas <Ctrl> Q para ver una
pequeña ventana emergente conteniendo la documentación técnica de esa función.
Python permite introducir elementos llamados cadenas de documentación o docstrings en el código fuente, de tal
forma que el contenido de los bloques docstring pueda luego ser usado para generar archivos de documentación
general sobre nuestros programas y módulos.
Una cadena de documentación es un string literal, encerrado entre comillas triples ("""), que puede desplegarse
en una única línea o en un bloque de varias líneas:

Los docstrings pueden usarse para especificar detalles de contenido y/o funcionamiento de una función, una
clase, un método, un módulo o incluso un paquete.
1. Una cadena docstring debe ser la primera línea del elemento que se está documentando (función,
clase, método, etc.)
2. Dentro de la cadena docstring, a su vez, la primera línea debe ser una descripción breve del
elemento que se está documentando, y comenzar con mayúscula y terminar con punto.
3. Si el docstring tendrá varias líneas, la segunda línea debería quedar en blanco.
4. A partir de la tercera línea, realizar un resumen más detallado del elemento documentado,
indicando la forma de usar ese elemento, sus parámetros y retornos (si los hay), excepciones, etc.
5. El docstring de un módulo debería listar las funciones y cualquier otro ítem que el módulo
contenga, con una línea de texto breve para cada uno. Esta lista breve debería brindar menos
detalles que el resumen general que cada ítem tendrá a su vez en sus propios docstrings.
6. El docstring de un paquete se escribe dentro del archivo __init__.py de ese paquete, y también
debería incluir una lista breve de los módulos y subpaquetes incluidos en él.
7. Finalmente, el docstring de una función debería resumir su comportamiento y documentar sus
parámetros, tipo de retorno, efectos secundarios, excepciones lanzadas y restricciones de uso (si
son aplicables). Los parámetros default deberían ser indicados, y también sus parámetros de
palabra clave.
Todo paquete y todo módulo contiene una variable global especial llamada __doc__, que contiene una cadena de
caracteres con todos los docstrings de ese módulo.
Si se trabaja directamente con el shell, la función help() del shell toma como parámetro el nombre de un ítem
(por caso, el nombre de una función) y muestra los docstrings de ese ítem (sin generar ningún archivo: sólo
muestra la cadena en consola estándar)
Python provee un módulo ejecutable llamado pydoc.py, que se ejecuta desde la línea de órdenes del sistema
operativo. Por lo tanto, la ruta de acceso a la carpeta que contiene a ese módulo (típicamente algo como
C:\Program Files\Python 3.3.0\Lib) debería estar almacenada en la variabla PATH del sistema operativo. Este
módulo puede generar archivos en formato html con los docstrings de un proyecto, y esos archivos se almacenan
dentro de las carpetas del propio proyecto.
Ficha 12

Arreglos Unidimensionales
Muchas veces el programador necesita manejar un gran volumen de datos y que permanezcan todos juntos en
memoria para poder accederlos y/ o modificarlos sin perder ninguno de ellos
Una estructura de datos es una variable que puede contener varios a la vez, ósea, que es como un conjunto de
valores almacenados en una misma variable.
Las estructuras de datos son útiles cuando se trata de desarrollar programas en los cuales se maneja un
volumen elevado de datos y/o resultados, o bien cuando la organización de estos datos o resultados resulta
compleja. Estas permiten agrupar en forma conveniente y ordenada todos los datos que un programa requiera,
brindando, además, muchas facilidades para acceder a cada uno de esos datos por separado.
Una estructura de datos muy usada en la programación es el arreglo unidimensional (vector)
Se trata de una colección de valores que se organiza de tal forma que cada valor o componente individual es
identificado automáticamente por un número designado como índice. El uso de los índices permite el acceso, uso
y/o modificación de cada componente en forma individual.
La cantidad de índices que se requieren para acceder a un elemento individual, se llama dimensión del arreglo.
Los arreglos unidimensionales se denominan así porque sólo requieren un índice para acceder a un componente.
Ej: El arreglo se denomina v y que la misma contiene una referencia al arreglo (es decir, contiene la dirección de
memoria donde está almacenado realmente el arreglo).

En Python los índices comienzan en cero y a partir de su índice cada elemento del arreglo referenciado por v
puede accederse en forma individual usando el identificador del componente:
En Python, los tipos que representan secuencias (tuplas, cadenas, rangos) permiten manejar variables de la
misma forma. Pero el tipo list representa secuencias mutables, mientras que los demás son inmutables. Esto
significa que al ser una secuencia mutable de valores de cualquier tipo, de forma que cada uno de esos valores
puede ser accedido y/o modificado a partir de su índice
Para crear un arreglo unidimensional v inicialmente vacío:

Si conoce de antemano los valores que desea almacenar, puede crearlo enumerando sus componentes:

En un arreglo en Python, no existe una casilla cuyo índice coincida con el tamaño del arreglo
El arreglo contiene 6 elementos y el ultimo índice es 5
ya que los índices comienzan en 0

El contenido puede mostrarse directamente con una sola llamada a la función print():

Salida…

Un arreglo en Python puede contener valores de tipos distintos:

Una variable de tipo list es mutable y por lo tanto los valores de sus casilleros pueden cambiar de valor:

Si se necesita crear un arreglo (variable de tipo list) con cierta cantidad de elementos iguales a un valor dado
(por ejemplo, un arreglo con n elementos valiendo cero), el truco consiste en usar el operador de multiplicación y
repetir n veces una list que solo contenga a ese valor:

La función constructora list() también puede usarse para crear una variable de tipo list a partir de otras
secuencias ya creadas:

Salida…

Tupla: los valores aparecerán en consola encerrados entre paréntesis.


Lista: valores aparecerán encerrados entre corchetes
Si el arreglo se creó vacío y luego se desea agregar valores al mismo, debe usarse el método append provisto
por el propio tipo list

el método append() agrega al contenido de la variable de tipo list el valor tomado como parámetro, pero lo hace
de forma que ese valor se agrega al final del arreglo.
Si se desea procesar un arreglo de forma que la misma operación se efectúe sobre cada uno de sus
componentes, es normal usar un ciclo for de forma se aproveche la variable de control del ciclo como índice para
entrar a cada casilla, realizando lo que se conoce como un recorrido secuencial del arreglo.

Si el arreglo está ya creado, se puede procesar a cada uno de sus componentes:

iterando directamente sobre ellos iterando sobre sus índices

Se puede usar la función len() para saber el tamaño o cantidad de elementos que tiene una variable de tipo list
Acceso a componentes individuales de una variable de tipo list
En Python las variables de tipo list que se usan para representar arreglos no son de tamaño fijo, sino que pueden
aumentar o disminuir en forma dinámica su tamaño, agregar o quitar elementos en cualquier posición o incluso
cambiar el tipo de sus componentes, además, se pueden usar índices negativos con cualquier tipo de secuencia
(listas, tuplas, cadenas, etc.) sabiendo que el índice -1 corresponde al último elemento, el -2 al anteúltimo, y así
sucesivamente

En Python, las variables de tipo list son de naturaleza mutable. Esto quiere decir que se puede cambiar el valor
de un elemento sin tener que crear una list nueva.

Si se desea eliminar un elemento en particular, se puede recurrir a la instrucción del

Note que la instrucción del puede usarse para remover completamente toda la lista o arreglo, pero eso hace
también que la variable que referenciaba al arreglo quede indefinida

Todo tipo de secuencia en Python permite acceder a un subrango de casilleros "rebanando" o "cortando" sus
índices.

La instrucción siguiente también usa un corte de índices pero esta vez para copiar toda una lista:

Importante: hay una diferencia entre asignar entre sí dos variables de tipo list, o usar corte de índices para
copiar un arreglo completo. Si la variable números representa un arreglo, y se asigna en la variable copia1,
entonces ambas variables quedarán apuntando al mismo arreglo
Sin embargo, cuando se usa el operador de corte de índices se está recuperando una copia de los elementos de
la sub-lista pedida. Por lo tanto, el siguiente script no copia las referencias, sino que crea un segundo arreglo,
cuyos elementos son iguales a los del primero (y cualquier modificación que se haga en una de las listas, no
afectará a la otra):

El acceso a subrangos de elementos mediante el corte de índices permite operaciones de consulta y/o
modificación de sublistas en forma muy dinámica y flexible.
Salida…
Definición por comprensión de variables de tipo list
La creación de listas por comprensión es una técnica sumamente flexible en Python. Sintácticamente, consiste
en escribir dentro de un par de corchetes una expresión (que puede ser no solamente una variable…) seguida de
un for que a su vez puede contener otros ciclos for y/o instrucciones condicionales if… El resultado será una
nueva lista en la que cada uno de sus elementos será a su vez el resultado de cada una de las iteraciones del for
sobre la expresión inicial.
Ficha 13
Arreglos: Algoritmos y Técnicas Básicas
Ordenamiento de un arreglo unidimensional (Algoritmo: Selección Directa)

Entre otras ventajas, la importancia del ordenamiento de un arreglo está en relación directa con la operación de
buscar valores dentro de ella.
Ordenar el arreglo de menor a mayor usando selección directa

Se comienza asumiendo que el menor está en la casilla cuyo índice es i = 0, y se recorre el resto del arreglo con j
comenzando desde el valor i+1. Cada valor en la casilla j se compara con el valor en la casilla i. Si el valor en v[i]
resulta mayor que v[j] entonces los valores se intercambian, y así se prosigue hasta que el ciclo controlado por j
termina.
El algoritmo presentado no ordena el arreglo: sólo garantiza que el menor valor será colocado en la casilla con
índice i = 0. Para que se ordene todo el arreglo: hacer que la variable i modifique su valor con otro ciclo,
comenzando desde cero y terminando antes de llegar a la última casilla (para evitar que j comience valiendo un
índice fuera de rango)
Búsqueda secuencial
Si el arreglo está desordenado, o no se sabe nada acerca de su estado, la única forma inmediata de buscar un
valor en él consiste en hacer una búsqueda secuencial:
Con un ciclo for se comienza con el primer elemento del arreglo. Si se encuentra el valor buscado, se detiene el
proceso y se retorna el índice del componente que contenía al valor. Si no se encuentra el valor, se salta al
componente siguiente y se repite el esquema. Si se llega al final del arreglo sin encontrar el valor buscado, lo
cual ocurrirá cuando i (la variable de control del ciclo) sea igual al tamaño del arreglo, la función de búsqueda
retorna el valor –1, a modo de indicador para avisar que la búsqueda falló. El algoritmo de búsqueda secuencial
en un arreglo se aplica en la función linear_search()

Búsqueda binaria
Si el arreglo estuviera ordenado se puede aplicar un método de búsqueda mucho más eficiente (en el sentido de
su velocidad de ejecución). La idea básica es la siguiente: se usan dos índices auxiliares izq y der cuya función es
la de marcar dentro del arreglo los límites del intervalo en donde se buscará el valor. Como al principio la
búsqueda se hace en todo el vector, originalmente izq comienza valiendo 0 y der comienza valiendo n-1 (siendo n
la cantidad de componentes del arreglo). Dentro del intervalo marcado por izq y der, se toma el elemento central,
cuyo índice c es: c = (izq + der) // 2 o sea, el promedio de los valores de izq y der.
Luego de esto, se verifica si el valor contenido en v[c] coincide o no con el número buscado. Si coincide, se
termina la búsqueda, y se retorna el valor de c para indicar la posición del número dentro del arreglo. Si no
coincide, es entonces cuando se aprovecha que el arreglo está ordenado de menor a mayor: si el valor buscado x
es menor que v[c], entonces si x está en el arreglo debe estar a la izquierda de v[c] y por lo tanto, el nuevo
intervalo de búsqueda debe ir desde el valor de izq hasta el valor de c-1. Entonces, se ajusta der para que valga
el valor c-1, y se repite el proceso descripto. Si el valor x es mayor que v[c], entonces la situación es la misma
pero simétrica hacia la derecha, y debe ajustarse izq para valer c+1. El proceso continúa hasta que se encuentre
el valor o hasta que el valor de izq se haga mayor que el de der (es decir, hasta que los índices se crucen), lo cual
indicará que ya no quedan intervalos en los que buscar, y por lo tanto el valor x no estaba en el arreglo (y en este
caso, la función que hace la búsqueda retornará el valor –1).
El proceso que se describió se denomina búsqueda binaria porque parte al arreglo en dos intervalos a partir del
valor central, y a cada intervalo en otros dos, hasta dar con el valor o no poder generar nuevos intervalos. Con
muy pocas comparaciones, el valor es encontrado (si existe). La desventaja obvia es que el arreglo debe estar
ordenado.
Ficha 14
Arreglos–Casos de Estudio I
En muchas ocasiones será necesario almacenar información en varios arreglos unidimensionales a la vez, pero
de tal forma que haya correspondencia entre los valores almacenados en las casillas con el mismo índice
(llamadas casillas homólogas). Se los suele designar como arreglos correspondientes o paralelos.

Siempre se debe cuidar que al trabajar con el arreglo se mantenga la correspondencia entre los elementos del
otro arreglo

Ficha 16
Arreglos Bidimensionales
Se define como dimensión de un arreglo a la cantidad de índices que se requieren para acceder a uno de sus
elementos.
La forma de implementar el concepto de tabla bidimensional o de dos entradas es usar arreglos bidimensionales
(también llamados comúnmente matrices) y en Python esto implica la idea de listas de listas (variables de tipo
list que en cada casilla contienen a otra list)

Creación y uso de arreglos bidimensionales en Python


Básicamente, un arreglo bidimensional o matriz es un arreglo cuyos elementos están dispuestos en forma de
tabla, con varias filas y columnas. Aquí llamamos filas a las disposiciones horizontales del arreglo, y columnas a
las disposiciones verticales.
Sabemos también una colección de tipo list puede contener otras list embebidas en ella y este hecho es el que
permite definir arreglos bidimensionales en Python. La forma más directa y simple de hacerlo es la asignación
directa de valores constantes:
la matriz se puede crear recordando una simple regla: un arreglo bidimensional en realidad es una colección de
arreglos unidimensionales, que forman las filas de la matriz.
Si sabemos de antemano qué valores necesitamos en cada fila y columna, podemos asignar en forma directa la
lista de listas que será la matriz

Esto es equivalente a escribir así la matriz:


Como los índices requeridos son dos, el arreglo entonces es de dimensión dos.

Sean n y m la cantidad de filas y columnas de la matriz. Hay varias formas de crear la estructura de una matriz:
La primera, consiste en partir de un arreglo vacío, al que se le agregan otros n arreglos vacíos para que sean sus
filas (inicialmente vacías...):

Salida…

Luego, a cada fila vacía se le agregan m valores default (None, cero o el que prefiera el programador), que serán
las m casillas de cada fila.
La segunda forma de hacerlo, un poco más compacta, consiste en comenzar creando n valores None (o lo que se
requiera) dentro de la matriz, para abrir espacio para las filas:

Salida…

Y luego reemplazar cada uno de los n Nones (las falsas filas...), por un vector de m casilleros... también valiendo
None (o lo que prefiera el programador...):
Salida…

La tercera forma de hacerlo, la más compacta, consiste crear la matriz por comprensión (ver Ficha 12...). En la
forma 2, hay un ciclo for que realiza n iteraciones, y en cada una crea un vector de m elementos valiendo None...
Y eso todo lo que se necesita escribir entre los dos corchetes para crear la matriz, con n filas conteniendo m
casilleros/columnas (con valor None inicial):
Salida…
Como vimos, una vez que la matriz ha sido creada el acceso a sus elementos individuales se hace con dos
índices: el primero selecciona la "fila" (o sea, una de las n sublistas o subarreglos) y el segundo selecciona la
"columna"

Recorrido y carga de una matriz


Si quiere recorrer una matriz completa (por ejemplo, para asignar o cargar por teclado valores en cada casilla),
lo típico es usar dos ciclos: uno recorre la filas, y el otro recorre las columnas de la fila seleccionada por el
primer ciclo.
Notar que si a es una matriz de n filas y m columnas, entonces len(a) retorna la cantidad de filas (el tamaño del
vector que apunta a cada vector fila), y len(a[f]) retorna la cantidad de columnas de la fila f (que es la misma en
toda la matriz si la misma es regular).

El índice en el primer par de corchetes selecciona SIEMPRE una fila (ya que selecciona uno de los vectores fila), y
el índice en el segundo par de corchetes selecciona SIEMPRE una columna, sin importar en qué orden estén los
ciclos que regulan a esas variables.

En el ejemplo, la variable c selecciona una columna porque al acceder a la matriz fue al segundo par de
cochetes, y la variable f selecciona una fila porque fue al primer par de corchetes. El ciclo que está por fuera,
determina si el recorrido será por filas o por columnas...
Totalización por filas y columnas
En muchas situaciones se requerirá procesar los datos contenidos en una matriz, de forma de obtener los totales
acumulados de cada una de sus filas y/o de cada una de sus columnas.
En el proceso de totalización por filas que desarrolla la función, el ciclo que recorre las filas va por fuera, y el
que recorre las columnas va por dentro:

Y totalización por columnas se logra ubicando por fuera el ciclo de las columnas, y por dentro el de las filas, pero
el orden de los índices en los corchetes para acceder a la matriz es siempre el mismo: primero el índice de fila y
segundo el índice de columna:

Matrices de conteo y/o acumulación


Es posible que en ciertas ocasiones se deba emplear una matriz de contadores o de acumuladores.
En la matriz de conteos se requieren dos índices para seleccionar el casillero que hará las veces de contador.
EJ: En este ejercicio, la matriz de conteos es usada para contar cuántos clientes que viajan a cada destino
posible, usaron cada forma de pago posible.
Como las formas de pago disponibles son tres, y los destinos son cinco, hay entonces un total de quince
combinaciones, cada una de las cuales requiere un contador. Cada fila representa un destino de viaje y cada
columna representa una forma de pago.
Ficha 17
Ordenamiento
En general, se suele llamar algoritmos simples o algoritmos directos a los que están basados en ideas intuitivas
muy simples, y algoritmos compuestos o algoritmos mejorados a los que se fundamentan en estrategias lógicas
muy sutiles y no tan obvias a primera vista.
Efectivamente existe una diferencia de rendimiento muy marcada en cuanto al tiempo que los algoritmos de cada
grupo demoran en terminar de ordenar el arreglo

Clasificación tradicional de los algoritmos de ordenamiento más comunes

Los algoritmos Simples o Directos son algoritmos sencillos e intuitivos, pero de mal rendimiento (demasiada
demora) si la cantidad n de elementos del arreglo es grande o muy grande o incluso si lo que se desea es
ordenar un archivo en disco. Los métodos presentados como Compuestos o Mejorados tienen un muy buen
rendimiento en comparación con los simples, y se aconsejan toda vez que el arreglo sea muy grande o se quiera
ordenar un conjunto en memoria externa.

Funcionamiento de los Algoritmos de Ordenamiento Simples o Directos

Ordenamiento por Intercambio Directo (o Bubblesort):


La idea esencial del algoritmo es que cada elemento en cada casilla v[i] se compara con el elemento en v[i+1]. Si
este último es menor, se intercambian los contenidos. Se usan dos ciclos anidados para conseguir que incluso los
elementos pequeños ubicados muy atrás, puedan en algún momento llegar a sus posiciones al frente del arreglo.

Puede verse que el algoritmo tiene que realizar n-1 pasadas para ordenarlo en el peor de los casos y en cada
pasada se va reduciendo la cantidad de comparaciones. Otro hecho notable es que el arreglo podría quedar
ordenado antes de la última pasada. Para controlar este tipo de situaciones, en el algoritmo se agrega una
variable a modo de bandera de corte que lo hace es detectar es que si no hubo intercambio controla la cantidad
de pasadas y se interrumpe antes de llegar a la pasada n-1 y el ordenamiento queda finalizado.
El algoritmo Quicksort aplica una estrategia recursiva conocida como Divide y Vencerás
Ordenamiento por Inserción Directa (o Inserción Simple):
Se comienza suponiendo que el valor en la casilla v[0] conforma un subconjunto. Y como tiene un solo elemento,
está ordenado. A ese subconjunto lo llamamos un grupo ordenado dentro del arreglo. Se toma v[1] y se trata de
insertar su valor en el grupo ordenado. Si es menor que v[0] se intercambian, y si no, se dejan como estaban. En
ambos casos, el grupo tiene ahora dos elementos y sigue ordenado. Así, también hacen falta n-1 pasadas, de
forma que en cada pasada se inserta un nuevo valor al grupo.

Algoritmos de Ordenamiento Compuestos o Mejorados: El Algoritmo Shellsort.


Los algoritmos designados como Compuestos o Mejorados están basados en la idea de mejorar algunos aspectos
que hacen que tengan mejor rendimiento cuando el tamaño del arreglo es grande o muy grande.
Ordenamiento de Shell (Shellsort u Ordenamiento por Incrementos Decrecientes):
El algoritmo de ordenamiento por Inserción Directa es el más rápido de los métodos simples, pues aprovecha que
un subconjunto del arreglo está ya ordenado e inserta nuevos valores en ese conjunto de forma que este siga
ordenado. El problema es que si llega a aparecer un elemento muy pequeño en el extremo derecho del arreglo,
cuando el grupo ordenado de la izquierda ya contiene a casi todo el vector, la inserción de ese valor pequeño
demorará demasiado, pues tendrá que compararse con casi todo el arreglo para llegar a su lugar final. La idea
es lanzar un proceso de ordenamiento por inserción, se comienza haciendo primero un reacomodamiento de
forma que cada elemento del arreglo se compare contra elementos ubicados más lejos, a distancias mayores que
uno, y se intercambien elementos a esas distancias. Luego, en pasadas sucesivas, las distancias de comparación
se van acortando y repitiendo el proceso con elementos cada vez más cercanos unos a otros. De esta forma, se
van armando grupos ordenados, pero no a distancia uno, sino a distancia h tal que h > 1. Finalmente, se termina
tomando una distancia de comparación igual a uno, y en ese momento el algoritmo se convierte en una Inserción
Directa para terminar de ordenar el arreglo Las distancias de comparación se denominan en general
incrementos decrecientes.
En general, digamos que no es necesario que esos incrementos sean demasiados: suele bastar con una cantidad
de distancias igual o menor al 10% del tamaño del arreglo, pero debe asegurarse siempre que la última sea igual
uno, pues de lo contrario no hay garantía que el arreglo quede ordenado. Por otra parte, es de esperar que los
valores elegidos como distancias de comparación no sean todos múltiplos entre ellos, pues si así fuera se
estarían comparando siempre las mismas subsecuencias de elementos, sin mezclar nunca esas subsecuencias.
Ficha 18
Registros
En algún momento, se necesitará almacenar valores de tipos diferentes en una misma estructura de datos para
describir algún elemento o entidad del dominio del problema para este tipo de casos se recurre a una estructura
de datos que contenga una sintaxis y que la forma de gestión sea clara.
Un registro es un conjunto mutable de valores que pueden ser de distintos tipos. Cada componente de un registro
se denomina campo (o también atributo, dependiendo del contexto). Los registros son útiles para representar en
forma clara a cualquier elemento (o entidad u objeto) del dominio o enunciado del problema que el programador
necesite manejar y cuya descripción pudiera contener datos de tipos diferentes.
Para definir variables de tipo registro en un programa, lo común es proceder primero a declarar un nuevo
nombre o identificador de tipo de dato para el registro. La declaración de un tipo registro se puede hacer en
Python con la palabra reservada class,

Las variables e1, e2, y e3 del ejemplo anterior se crean como registros de tipo Empleado (también se dice que se
crean como instancias de ese tipo) a través de la función constructora Empleado(), que está automáticamente
disponible para tipos definidos mediante la palabra reservada class . Cada vez que se invoca a esa función,
Python crea un registro vacío, sin ningún campo, y retorna la dirección de memoria donde ese registro vacío
quedó alojado:
La dirección retornada por la función constructora se asigna en la variable e1, que de allí en adelante se usa para
acceder y gestionar el registro (se dice que e1 está apuntando al registro o que está referenciando al registro).
Cada variable se crea inicialmente como un registro vacío, entonces luego el programador debe ir agregando
campos (o atributos) a esas variables según lo necesite, usando el operador punto (.) para hacer el acceso: Con
esto se forma lo que se conoce como el identificador del campo, y a partir de aquí se opera con ese campo en
forma normal, como se haría con cualquier variable común.

Cada registro tiene su propio valor, que es independiente del valor que ese mismo campo tenga en los otros
registros.
Además, como un registro es una estructura mutable , el valor de cualquier campo puede ser modificado cuando
se necesite
También recuerde que en Python las variables no quedan atadas a un tipo fijo y estático, y nada impediría
entonces que se cree una cuarta variable de tipo Empleado

En lugar de crear cada campo uno por uno en cada variable, obviamente se puede usar una función que haga ese
trabajo:

La función init() la que finalmente "define" la estructura de campos del registro que entra como primer
parámetro. Y como está tomando la dirección del registro (y NO una copia del mismo), los cambios hechos dentro
de la función vuelven al registro que se envió como parámetro actual (sin necesidad de un return).
En Python una variable de tipo registro contiene en realidad la dirección de memoria del registro asociado a ella
(se dice la variable es una referencia a ese registro). Entonces si se asignan dos variables de tipo registro entre
ellas (por ejemplo, dos registros de tipo Empleado) no se estará haciendo una copia del registro original, sino una
copia de las direcciones de memoria, haciendo que ambas variables queden apuntando al mismo registro.
Dos referencias apuntando a registros diferentes Dos referencias apuntando al mismo registro

El registro apuntado originalmente por e1 queda des referenciado: esto significa que el programa ha perdido la
dirección de memoria de ese registro y ya no hay forma de volver a utilizarlo.
En la función init(), el registro se creaba fuera de la función (como registro vacío) y se enviaba como parámetro
para que la función cree sus campos y los deje asignados. Pero se puede hacer que init() haga todo el trabajo,

creando internamente el registro devolviéndolo con return:

Creación con Función Constructora


El concepto de registro como agrupamiento de variables de tipos que pueden ser diferentes es propio del
contexto de la Programación Estructurada (PE) y en este contexto un registro es una colección de variables que
pueden ser de tipos diferentes, pero un registro no puede contener funciones.
En la POO, una clase es un tipo de datos muy similar al registro de la PE, pero con una primera diferencia
esencial: una clase puede contener funciones además de campos.
Los elementos que en un registro se llaman campos (o sea, las variables definidas dentro del registro), reciben el
nombre de atributos cuando se definen en una clase. Y si una función se define dentro de una clase, pasa a
designarse como un método (en lugar de una función).
Finalmente, las variables que se definen en base a un tipo registro se llaman ellas mismas registros (o también
instancias), pero las variables que se definen en base a una clase se suelen designar como objetos (aunque aquí
también cabe la designación de instancias). En Python, la palabra reservada class está claramente pensada para
definir clases (y no registros).
Python provee clases… no registros.
Uno de los métodos más importantes que una clase puede contener se designa como método constructor
El objetivo de un constructor, es la creación y la inicialización de una instancia. En Python, un método constructor
debe llamarse específicamente __init__() y debe definirse dentro del ámbito de la clase.

DEBE tomar un parámetro self que apunta al registro a crear, se declara dentro de la clase del registro y realiza
en forma automática la creación del registro y su retorno.
Si la clase Empleado tiene definido el constructor, entonces la siguiente instrucción crea un objeto o registro e1
de tipo Empleado, y lo inicializa con los valores enviados como parámetros actuales:

La parte derecha de la asignación anterior es una invocación al constructor. Aun cuando el nombre __init__() no
está presente en esa invocación, Python interpreta que si el nombre de una clase se usa a la derecha de una
expresión de asignación y se le pasan parámetros, entonces se está invocando implícitamente al constructor de
esa clase. Además no es necesario enviar como parámetro al propio objeto e1 que se quiere crear: Python
también interpreta que si se invoca a un constructor, el objeto que se construye es enviado como parámetro a
ese constructor, y el constructor automáticamente retorna la dirección del objeto creado (sin necesidad de
incluir un return) razón por la cual el retorno del constructor es asignado en la variable e1.
Una vez que el registro u objeto ha sido creado e inicializado de esta forma se usa el nombre de la variable
registro seguido de un punto y luego el nombre del campo que se quiere acceder, como se ve en la función
write() del ejemplo:

Arreglos de registros (o vectores de registros) en Python


Si se necesita trabajar con un número grande de registros al mismo tiempo, se puede crear un arreglo
unidimensional (o vector de registros) que los contenga. El proceso de creación, carga y procesamiento no es
complejo. Lo primero es definir la clase para el tipo de registro que se necesita guardar en el vector:
Si se conoce de antemano la cantidad n de registros a almacenar, una forma de crear el arreglo pedido consiste
en declarar primero la referencia a ese arreglo

El arreglo de referencias todavía no contiene elementos listos para usar deben crearse los registros que serán
apuntados desde cada casilla del arreglo, y recién entonces comenzar a usar esos objetos.

Esto haría que cada casilla del arreglo contenga ahora una referencia a un registro de tipo Estudiante, pero con
todos sus campos valiendo los valores default que asigna la función constructora (ya que al invocarla en el
momento de crear cada registro, no le hemos enviando ningún valor como parámetro formal). Los valores None
ya no están en cada casillero, y fueron reemplazados por las direcciones de cada uno de los registros. Otra forma
es cargar por teclado los datos a almacenar en cada uno, y luego se creen los registros con sus campos
inicializados de forma definitiva.

puede lograrse el mismo resultado dejando los valores default de la función constructora __init()__, y luego
creando y asignando directamente los campos en cada casillero. Lo anterior es equivalente a:

Ahora cada casilla del vector es una


referencia o puntero a un registro de tipo
Estudiante... Y el resto es procesar esos
registros...

Función To_string()

La función to_string() usa en forma directa los campos que sean de tipo cadena y también convierte cada campo
numérico del registro a una cadena de caracteres con la función predefinida str() que Python ya provee. Cada
cadena así obtenida se une a otra cadena descriptiva con el operador +

Sin embargo, para lograr un ajuste más fino en cuanto a la justificación hacia la izquierda y el espaciado entre
valores dentro de la cadena final, se está usando aquí el método format() incluido dentro de la clase str que
representa al conjunto de cadenas de caracteres en Python
El método format() es invocado por una cadena de caracteres, y retorna esa misma cadena pero ajustada al
formato indicado por los parámetros enviados a format() y por algunos elementos contenidos en la propia cadena
que se designan como campos de reemplazo. Los campos de remplazo se componen de algún tipo de indicador
de formato encerrado entre dos llaves

Cuando el método es invocado, el campo de reemplazo {0} en la cadena es reemplazado por el valor (convertido
a cadena) del primer parámetro que haya recibido (en este caso, el valor de la variable a). El segundo campo de
reemplazo (o sea, {1}) se reemplaza con el valor del segundo parámetro enviado a format() (en este caso, el valor
de b), y así en forma sucesiva hasta agotar todos los campos de reemplazo.
Salida…

La cadena con la que se invoca al método format() puede estar completamente formada por un campo de
reemplazo que puede contener no sólo números que referencien a un parámetro, sino otros símbolos que tienen
significados diferentes según como se los agrupe.

Aquí, la cadena '{:<30}' está compuesta por un campo de reemplazo, que contiene a su vez la secuencia ':<30'. Esta
está indicando que la cadena que vaya a sustituir a ese campo, debe ser alineada o justificada a la izquierda (' :< ')
y la cadena completa debe ajustarse hasta ocupar 30 caracteres de largo. Si la cadena original no llegase a
completar 30 caracteres, los que falten hasta llegar a 30 hacia la derecha serán llenados con espacios en blanco.
Hasta aquí la cadena cad1 contiene una cadena como 'Nombre: Juan Pérez ' con 30 caracteres de largo.
Además en el script se está usando el operador += para concatenar (agregar al final) una segunda cadena a la
que ya contenía la variable cad1. Es de la forma '{:<10}'.format('Edad: ' + str(edad)) y esto indica que la nueva
cadena debe también ajustarse a la izquierda, pero completarse hasta llegar a 10 caracteres.
Total 30+10 = 40 caracteres de largo

:< Justificado a la Izquierda


:> Justificado a la Derecha
:^ Cadena centrada

Matrices de registros en Python


Se pueden crear arreglos bidimensionales que contengan referencias a registros.
Ejemplo:

se quiere crear una matriz est en la que cada fila f contenga los datos de los estudiantes que cursan en el año o
nivel f, y cada columna c se usa para distinguir el número de orden de cada estudiante en ese nivel.

Cada casillero de la matriz en este momento vale None y no hay ningún registro de tipo Estudiante, lo siguiente
es crear esos registros y asignarlos en cada casilla.

Ahora cada casillero contiene un registro. Por lo tanto, el acceso al campo legajo del registro ubicado en la
casilla est[f][c] se realiza con el identificador est[f][c]. legajo y luego asignando en ese campo un valor o usando
su valor como parte de una expresión cualquiera.

Generación de un arreglo de registros con contenido aleatorio


Ficha 19
Pilas y Colas
Todos los lenguajes proveen estructuras de datos (ED) listas para usar (en Python, las tuplas, las listas, las
cadenas, etc). Pero muchas veces el programador necesita organizar su datos en alguna forma que quizás no
esté prevista por el lenguaje y combinarlas con las que el lenguaje ya ofrece.
Las ED que un lenguaje ya provee, se suelen designar como estructuras nativas, mientras que aquellas que el
programador diseña se designan como estructuras abstractas.
Por ejemplo: si el programador necesita definir un nuevo tipo basado en un registro para representar libros,
entonces el tipo o estructura Libro es una estructura abstracta, y se diseña sobre un registro (o clase) que en
Python es un tipo o estructura nativa.
El proceso llevado a cabo por el programador para crear el nuevo tipo abstracto (Libro en este ejemplo), se
conoce con el nombre general de implementación del tipo abstracto.
Una vez que el programador descubre que debe implementar un nuevo tipo abstracto, el paso inicial es aplicar
un mecanismo conceptual designado como mecanismo de abstracción: es el proceso mediante el cual se intenta
captar y aislar la información y el comportamiento esencial de una entidad, elemento u objeto del dominio de un
problema.
Mecanismo de abstracción

Captar y aislar los datos Identificar los procesos


relevantes. aplicables sobre esos datos.
Abstracción de Datos Abstracción Funcional

Programación Estructurada

Pilas
Una pila es una estructura lineal (cada elemento tiene un único sucesor y un único antecesor) en la cual los
elementos se organizan de forma tal que uno de ellos se ubica al principio de la pila y los demás se enciman o
apilan uno sobre el otro a partir del primero en ser insertado (y este queda ubicado al fondo de la pila).
Para retirar un elemento del frente, la operación de inserción puede entenderse como una operación de empujar
hacia abajo los datos y dejar encima al último que ingresa, se suele designar como push() a la función que la
implementa. Y la operación de extraer un elemento se puede entender como la expulsión hacia arriba del
elemento que está en el tope, esa función suele designarse como pop().
Las pilas son una ED muy útiles donde se debe invertir una secuencia de entrada: el último en ser insertado es el
primero en ser retirado, y por lo tanto el primero en ser procesado.
Esta forma de procesamiento con inversión de entradas se conoce como LIFO (iniciales de last in – first out:
último en entrar – primero en salir), y por ello mismo las pilas suelen designarse como estructuras tipo LIFO.
Observemos que el segmento de memoria que se conoce como stack segment se comporta como una pila de
bloques de memoria para soportar el esquema de llamadas de funciones que pudiera disparar un programa. A
pesar de su apariencia sencilla, como vemos las pilas tienen una fuerte participación en el software de base de
una computadora.
• Abstracción de datos: Lo típico es usar un vector para simular la propia pila. El fondo de la pila se puede
hacer coincidir con el casillero cero del vector, y el elemento del frente estará entonces en la última
casilla.
• Abstracción funcional: La operación de inserción de un nuevo elemento puede hacerse con un función
push(), y la eliminación con otra función llamada pop() (entre otras operaciones...)
Colas
Una cola es una estructura lineal (cada elemento tiene un único antecesor y un único sucesor) en la cual los
elementos se organizan uno detrás del otro, quedando uno de ellos al principio (o al frente) de la cola y otro en el
último lugar de la misma (o al fondo). Para insertar un elemento se utiliza la función add() se hace de forma tal
que el nuevo componente queda último en la cola. Para eliminar un elemento la función remove() elimina aquél
que está primero. Entonces, cada elemento que se inserta queda último en la fila y será también el último en ser
retirado, las colas suelen designarse también genéricamente como estructuras tipo FIFO (por First In – First Out:
primero en entrar, primero en salir)
Las colas son estructuras de datos muy útiles en programas que requieren efectuar lo que se denomina una
simulación de situaciones de espera frente a un puesto de servicio.
Las colas preservan en forma natural el orden original de una secuencia de entrada.

Una cola también puede implementarse usando un arreglo como soporte, y otra vez esto es directo y
relativamente sencillo en Python, lo común es que las inserciones se hagan a partir de la última casilla de la
derecha (usando append()) y las eliminaciones se hagan a partir de la primera casilla de la izquierda (usando del)
Ficha 20 - 21
Análisis de Algoritmos
El Análisis de Algoritmos es la rama de las Ciencias de la Computación que se orienta a técnicas para medir la
eficiencia de un algoritmo, lo cual es útil si se quiere comparar algoritmos que resuelven el mismo problema, o
para tener una medición formal del rendimiento de un algoritmo dado.
Esa medición de eficiencia o rendimiento, se hace con relación a algún factor o parámetro medible. Los dos
factores que típicamente se miden, son el tiempo de ejecución y el consumo de memoria, aunque el primero de
ellos suele ser el más comúnmente aplicado.
Por otra parte, el análisis comparativo suele centrarse en dos situaciones: el rendimiento del algoritmo en el
caso promedio, y el rendimiento del mismo en el peor caso. Ambas situaciones se refieren respectivamente al
comportamiento del algoritmo evaluado cuando se presenta la configuración de datos más común (o caso
promedio, que suele ser aquella que surge de tomar los datos en orden estrictamente aleatorio) o bien cuando se
presenta la configuración de datos más desfavorable (o peor caso).
La meta es plantear fórmulas que permitan estimar de alguna forma el tiempo de demora o la cantidad de
memoria empleada, y NO simplemente hacer pruebas y cronometrar tiempos o medir consumo de bytes: la
fórmula es general, mientras que la medición puntual refleja lo que ocurrió en esa prueba, bajo esas
circunstancias. Nos centraremos de aqui en más en el factor tiempo. Para el planteo de esas fórmulas, se toma
como entrada la cantidad de datos n (o tamaño del problema) que el algoritmo debe procesar, y se intenta
detectar la operación crítica: aquella que por repetirse muchas veces, hace que el algoritmo demore lo que
demora. Luego se intenta deducir qué fórmula mide mejor la cantidad de veces que se ejecuta la operación
crítica para valores de n cada vez mayores. Y en lo que resta de esta explicación supondremos un análisis del
peor caso (pesimista pero más simple de plantear).
Ejemplo: El algoritmo de búsqueda secuencial en un arreglo de tamaño n, tiene su peor caso cuando el valor a
buscar está muy al final (o no está) o sea, hace todas las n comparaciones Se dice entonces que el tiempo
esperado para el algoritmo de búsqueda secuencial en el peor caso, está en el orden de n. En símbolos O(n)
orden lineal.
Esta forma de expresar el rendimiento de un algoritmo se designa como notación O ("notación O mayúscula" o
también como "notación Big O")
Un análisis similar puede hacerse para la búsqueda binaria este algoritmo partirá en dos al arreglo tantas veces
como sea necesario, quedándose con un segmento e ignorando al otro, hasta dar con el valor buscado. Entonces,
la cantidad de comparaciones en el peor caso es aproximadamente igual al número de veces que se divide por
dos al vector. Por lo tanto, dado un número n > 1, la cantidad de veces que podemos dividir por dos hasta obtener
un cociente de 1 es igual al logaritmo de n en base dos o bien, log(n). De allí que la búsqueda binaria a lo sumo
realizará log(n) particiones, y así el tiempo de ejecución de ese algoritmo en el peor caso es O(log(n)). O también
algoritmo de tiempo de ejecución de orden logarítmico.
Esto significa que, en la búsqueda secuencial si un arreglo tiene n = 1000 elementos la búsqueda hará 1000
comparaciones en el peor de los casos mientras que la binaria lo hará en 9 o 10 comparaciones en el peor de los
casos, o sea que al crecer n el crecimiento logarítmico será muy suave. Quiere decir que si pueden diseñarse
algoritmos cuyo comportamiento sea logarítmico, se podrá estar seguro que estos serán básicamente eficientes
en cuanto al tiempo, y muy estables a medida que el número de datos crece.
En el caso del ordenamiento por Selección Directa se trata de dos ciclos for anidados, de forma que el 1º de ellos
hace n vueltas, y el 2º hace otras n por cada una que da el primero. Esto lleva a un esquema de n * n
repeticiones, de forma que en cada una de ellas se hace una comparación. Esto sugiere que estará en el orden n
al cuadrado, con lo que el tiempo de ejecución también será O(n2) u orden cuadrático.
Un algoritmo o acción que demore siempre lo mismo para procesar datos sin importar si aumenta el valor del
tamaño de esa cantidad de datos por ej es el acceso a un componente individual de un arreglo solo vasta con
consultar V[i] y se accede al componente en el mismo orden de tiempo ya sea que tenga 2 o mas elementos.
Cuando un algoritmo o proceso se comporta de esta forma, denotamos su tiempo como O(1) (orden uno u orden
proporcional a uno). También se dice que dicho algoritmo tiene tiempo de ejecución de orden constante.
Ejemplo: En el algoritmo de ordenamiento por selección simple, el tamaño del problema es el tamaño n del
vector. Si la operación crítica es la comparación de dos elementos, entonces siempre cae en el peor caso, ya que
siempre hace todas las comparaciones posibles. Queremos una fórmula que nos diga cuántas comparaciones
hace el algoritmo para distintos valores de n. El algoritmo hace n-1 pasadas. En la primera, hace n-1
comparaciones, en la segunda hace n-2, y sigue así hasta la última pasada en la que sólo hace una comparación.
Por lo tanto, la cantidad total de comparaciones es t(n) = (n-1) + (n-2) + (n-3) + ... + 3 + 2 + 1. Pero se puede probar
que eso equivale a t(n) = ((n-1) * n) / 2, o bien: t(n) = ½ n2 - ½ n
La fórmula anterior fue el resultado de un análisis de conteo riguroso o exhaustivo de operaciones críticas. Pero
en muchos casos, para un programador es suficiente un análisis más general, que solo permita poner de
manifiesto cuál es el comportamiento de la función para valores grandes de n (¿es cuadrática? ¿es lineal? ¿es
exponencial?...)
Eso se conoce como análisis asintótico: se busca captar la forma general de variación de la función, y para ello,
se aplica una notación conocida como notación "O mayúscula" o "Big O": se expresa la función, pero sin de
constantes y términos que no son dominantes cuando n tiende al infinito.

Conteo exhaustivo: Análisis asintótico (Big O):


2 2
t(n) = ½ n - ½ n t(n) = O(n )

Las funciones que vimos (y cualquier otra en notación Big O) se conocen como funciones de orden de
complejidad, y para n que tiene al infinito crecen de forma tal que:
O(1) < O(log(n)) < O(n) < O(n*log(n)) < O(n2) < O(n3) < O(2n)
La notación Big O se usa para indicar un límite o cota superior para el comportamiento de una función. Si se dice
que un algoritmo de ordenamiento tiene un tiempo de ejecución en el peor caso de O(n2), o sea que ese algoritmo
no se comportará peor que n2 (o alguna función múltiplo de n2) en cuanto al tiempo de ejecución.
Formalmente, decir que una función f está en el orden de otra función g, implica afirmar que eventualmente, a
partir de cualquier valor suficientemente grande de n, la función f siempre será menor o igual que la función g
multiplicada por alguna constante c mayor a cero. En símbolos:
Si f(n) es O(g(n))  f(n)  c*g(n)
(para todo valor n suficientemente grande y algún c > 0)
Consideraciones Prácticas
Consejos prácticos para intentar plantear una función o relación de orden para el tiempo de ejecución de un
algoritmo dado...
1. Dado el algoritmo a analizar (diagrama, pseudocódigo, programa) tenga claro el factor a analizar: tiempo
o memoria.
2. Determine el tamaño del problema: la variable que indica cuántos datos serán procesados.
3. Si necesita un conteo exhaustivo o riguroso, tendrá que aplicar conocimientos y pasos matemáticos
detallados y cuidadosos para dar con la fórmula buscada...
4. Si le basta un análisis asintótico, y ya tiene una función de conteo exhaustiva, pásela a notación Big O:
quédese con el término dominante y prescinda de constantes superfluas. En general, no diga que el tiempo
es O(n3 + n2): el término n2 en el infinito es irrelevante frente a n3, y por lo tanto su tiempo asintotico es
O(n3).
5. Si no tiene una función de conteo exhaustiva, identifique la estructura general del algoritmo con respecto
a la operación crítica, e intente hacer un conteo en forma general. Ayúdese con estos patrones:
Operación crítica en un sólo ciclo de n repeticiones: t(n) = O(n).
Operación crítica dentro de dos ciclos anidados de n repeticiones cada uno: t(n) = O(n2). Y si tiene k ciclos
anidados, entonces t(n) = O(nk).
Si el algoritmo consta de varios bloques independientes entre sí, con tiempos de ejecución diferentes,
entonces el tiempo de ejecución asintótico es el que corresponde al bloque con tiempo mayor. Por
ejemplo, si en su algoritmo hay un primer bloque que ejecuta en tiempo O(n) y luego dos bloques más que
ejecutan en tiempos O(n2) y O(log(n)), entonces el tiempo completo sería t(n) = O(n) + O(n2) + O(log(n)) pero
esto es asintóticamente igual a O(n2), por lo que t(n) = O(n2).
Si todo su algoritmo es un único bloque de instrucciones de tiempo constante (asignaciones o condiciones,
por ejemplo) sin ciclos ni procesos ocultos dentro de una función, entonces todo el algoritmo ejecuta en
tiempo constante t(n) = O(1) (ya que sería t(n) = O(1) + O(1) + … + O(1) que es lo mismo que t(n) = O(1).
Si su algoritmo toma un bloque de n datos, lo divide en 2, aplica una operación de tiempo constante y
luego sigue con sólo una de las mitades y la vuelve a dividirla en 2, y así hasta no poder hacer otra
división, entonces su algoritmo tiene tiempo t(n) = O(log2(n)) que es lo mismo que t(n) = O(log(n)) (la base
del logaritmo se puede obviar).
6. No incluya constantes en la expresión de orden, salvo las del exponente del término dominante. En
general no dirá O(2n) sino simplemente O(n). La notación O rescata la forma de variación del término
dominante y por eso las constantes pueden despreciarse.
La notación Big O permite estimar una cota superior para el comportamiento de una función: Si f(n) es
O(g(n)), entonces f(n) será siempre menor o igual que g(n) (o que algún múltiplo de g(n)) a medida que n
se hace cada vez más grande.
Pero a veces se necesita encontrar una cota inferior para una función, o se necesita acotar tanto en
forma superior como en forma inferior a esa función, y para ello se tienen otras relaciones de orden, que
mostramos en la siguiente tabla:
Ficha 22
Archivos
Al terminar un programa si los datos cargados o resultados no quedan resguardados para que puedan
reutilizarse se dicen que son estructura de datos de naturaleza volatiles. Para dar solución a ese inconveniente
existen los archivos que son estructuras de datos persistentes.
Un archivo es una estructura de datos que, en lugar de almacenarse en memoria principal, se almacena en
dispositivos de almacenamiento externos (como discos o memorias flash) y por lo tanto el contenido de un
archivo no se pierde al finalizar el programa que lo utiliza. El uso de archivos permite el manejo de grandes
volúmenes de datos sin tener que volverlos a cargar desde teclado cada vez que se desee procesarlos. Por otra
parte, una vez que un archivo fue creado y grabado en un dispositivo externo, este puede ser utilizado por
cualquier otro programa, ya sea para obtener datos del archivo y/o para modificar los datos del mismo (siempre
y cuando la estructura interna de ese archivo sea conocida por quien desarrolla esos otros programas).
Los datos que se graban en un archivo se representan en sistema binario y por cada dato se utilizan tantos bytes
como sea necesario para representar ese dato. De allí que, entonces, el tamaño de un archivo es la cantidad total
de bytes que contiene el archivo.
Archivos de texto: Todos los bytes del archivo son interpretados como caracteres (tabla ASCII). Si un archivo es
de texto también contiene bytes, pero se asume que todos esos bytes representan caracteres que pueden ser
visualizados en pantalla, con la única excepción del caracter de salto de línea (“\n”). (Aquellos cuyo valor ASCII
está entre 32 y 126), y cualquier editor de textos puede abrirlos y acceder a su contenido. Ej.: Los archivos fuente
en Python son de texto.
Archivos Binarios: Las secuencias o bloques de bytes que el archivo contiene representan información de
cualquier tipo (números en formato binario, caracteres, valores booleanos, etc.) y no se asume que cada byte
representa un caracter. Pueden contener bytes cuyo valor numérico sea menor a 32, y esos son caracteres de
control, no visualizables. Ej.: los archivos ejecutables .exe (Windows) y cualquiera que no sea de texto.

Todos los archivos contienen información representada en binario, y por lo tanto, todos los archivos son binarios.

Archivos binarios en Python: conceptos básicos


En Python el acceso a datos almacenados (archivos) en dispositivos externos, se realiza mediante objetos con
una variable designada como file object. Una variable (u objeto) file object se crea con la función open(), que
además abre el archivo y lo deja disponible para ser accedido.

El primer parámetro de la función es una cadena de caracteres con el nombre físico (o file descriptor) del
archivo a abrir: es el nombre con el cual el archivo figura grabado en el sistema de carpetas del sistema
operativo. Este nombre físico puede incluir también la ruta de acceso a ese archivo.
Si no se indica la ruta de acceso, el intérprete Python lo buscará en la carpeta actual del proyecto o programa
que se está ejecutando. El segundo parámetro es otra cadena de caracteres indicando el modo de apertura para
el archivo. El modo de apertura puede ser alguna de las letras a, r, o w, las cuales pueden ir acompañadas de un
signo +, y eventualmente también de una letra b o de una t.
La función open() es la encargada de abrir el canal de comunicación entre el dispositivo que contiene al archivo y
la memoria principal. El objeto creado (m) y retornado por la función se aloja en memoria y contiene diversos
atributos con datos que permiten manejar el archivo. Por ese motivo, ese objeto se suele designar como el
manejador del archivo.
El método close() se usa para cerrar esa conexión y liberar además cualquier recurso que estuviese asociado al
archivo. Luego de invocar a close(), la variable u objeto que representaba al archivo (el manejador del archivo)
queda indefinida. En Python los archivos son cerrados automáticamente cuando la variable para accederlos sale
del ámbito en que fue definida, por lo que en muchos contextos no es estrictamente necesario que se invoque a
close().

Al invocar a open(): Se crea el archivo si el


Crear el archivo datos.dat en mismo no existía o lo abre y elimina su
la carpeta del proyecto, contenido si ya existía. Si en lugar de 'wb' se
dejándolo vacío (tamaño = 0). usase el modo 'ab', si el archivo ya existiese, su
contenido no será eliminado y los nuevos datos
se agregarán al final (por lo cual el modo 'ab'
suele ser el que se usa para agregar nuevos
datos a un archivo
Si es 'rb' y el archivo que se
quiere abrir no existe en el
momento de invocar a open(),el
programa lanzará un error de
runtime (lo que se conoce
como una excepción) y se
interrumpirá

Un objeto file object que se crea con open() contiene numerosos métodos adicionales para el manejo del archivo.
Entre ellos, los métodos read() y write() permiten respectivamente leer y grabar datos en el archivo. Ambos
métodos son directos y simples de usar cuando se trata de archivos de texto, pero no son tan directos ni tan
simples cuando se trata de leer o grabar en un archivo binario, sobre todo si la intención es trabajar con
registros.
Por este motivo, para la lectura y grabación de datos en un archivo binario se emplea una técnica diferente, que
se designa como serialización que es un proceso por el cual el contenido de una variable de estructura compleja
(un registro, un objeto, una lista, etc.) se convierte automáticamente en una secuencia de bytes listos para ser
almacenados en un archivo, pero de tal forma que luego esa secuencia de bytes puede recuperarse desde el
archivo y volver a crear con ella la estructura de datos original.
En la serialización se emplea el módulo pickle, que entre otras funciones, provee las dos que necesitaremos:
dump() (graba) y load() (lee).

Archivos binarios en Python: recorrido secuencial y acceso directo


Un archivo (binario o de texto) puede entenderse como un gran arreglo o vector de bytes ubicado en memoria
externa en lugar de estar alojado en memoria principal. Cada uno de los bytes que se graban en un archivo, está
numerado desde el cero en adelante. El número del primer byte que está afuera del archivo, indica también el
tamaño en bytes del archivo:

El sistema operativo es el responsable de recordar en qué lugar termina un archivo. Ese punto se conoce como el
final del archivo o end of file. Los bytes que siguen al final del archivo no pertenecen al él pero aun así están
numerados en forma correlativa, continuando con la numeración que traía el archivo.
La numeración de los bytes comienza en cero, entonces el número total de bytes (o tamaño) del archivo coincide
con el número del primer byte que se encuentra fuera del mismo.
Para obtener el tamaño de un archivo sa hace mediante la función getsize() incluida en el módulo os.path

La función os.path.getsize() toma como parámetro el nombre físico del archivo 'libros.dat', y retorna su tamaño
en bytes. No es necesario que el archivo esté abierto con open() para poder usar esta función. Todo archivo
cuenta con una especie de cursor o indicador o marcador interno llamado file pointer, que es una variable de tipo
entero tal que, mientras el archivo está abierto, contiene el número del byte sobre el cual se realizará la próxima
operación de lectura o de grabación. Tanto las operaciones de lectura como las de grabación, comienzan la
operación en el byte indicado por el file pointer y al terminar la misma, dejan el file pointer apuntando al byte
siguiente a aquel en el cual terminó la operación. Si una operación de salida graba bytes detrás del byte de
finalización del archivo, entonces el tamaño del archivo crece y se ajusta para abarcar los nuevos bytes.
En general el valor del file pointer es gestionado automáticamente por las funciones y métodos básicos para
gestión de archivos: open() abre el archivo y asigna el valor inicial al file pointer (típicamente el valor cero),
mientras que dump() y load() (si se aplica serialización a través de pickle) ajustan el valor del file pointer para
dejarlo apuntando al byte en donde comenzará la próxima operación.
Cuando no se necesite recorrer de manera secuencial el archivo para acceder en forma directa a ciertos datos o
posiciones en el archivo se necesita poder controlar en forma manual el valor del file pointer.

En ese sentido, los objetos tipo file object creados con open() contienen un método tell() que retorna el valor del
file pointer en forma de un número entero, y también un método seek() que permite cambiar el valor del mismo
(y por lo tanto, seek() permite elegir cuál será el próximo byte que será leído o grabado). La función tell() es muy
útil cuando por algún motivo se requiere saber en qué byte está posicionado un archivo(leer el contenido
completo en forma secuencial, usando un ciclo) en un momento dado. Pero si se desconociera el número de
registros y se intenta leer un bloque de bytes ubicado más allá del final del archivo, los métodos y funciones de
lectura provocarán un error de runtime y el programa se interrumpirá. Para solucionar eso, Si el file pointer
está apuntando a un byte cuyo número es menor que el tamaño, entonces el file pointer está posicionado en un
byte que pertenece al archivo y la lectura sería en ese caso válida.
Para recorrer y leer secuencialmente todos los registros de un archivo, se puede entonces usar un ciclo while
que controle que el file pointer no llegue al final del archivo

El tamaño del archivo equivale al


número del primer byte que está
fuera del archivo (y getsize()
retorna ese número).

Al comenzar el ciclo, tell() retorna un


0 (el archivo se acaba de abrir) y si el
archivo no está vacío, la condición del
ciclo es True.
Si el file pointer en
alguna vuelta llega al En la primera vuelta, load() lee el registro
final del archivo, la que comienza en el byte 0, y al terminar,
condición será False y el cambia el valor del file pointer al byte de
inicio del siguiente registro y vuelve al ciclo
ciclo se detendrá. luego de mostrar el registro.
El método seek(offset, from_what) permite cambiar el valor del file pointer En recibe dos parámetros: el primero
indica cuántos bytes debe moverse el file pointer, y el segundo indica desde donde se hace ese salto (un valor 0
indica saltar desde el principio del archivo, un 1 desde donde está el file pointer en este momento y un 2 indica
saltar desde el final). El valor por default del segundo parámetro es 0, por lo cual por omisión se asume que los
saltos son desde el inicio del archivo. El valor del segundo parámetro puede ser indicado como un número o por
medio de una de las siguientes tres constantes predefinidas (las tres dentro del módulo io)

a.) Sin importar donde esté ubicado el file pointer en este momento, suponga que se quiere
cambiar su valor para que pase a valer 10. Podemos hacerlo así:
m.seek(10, io.SEEK_SET)

b.) Sin importar donde esté ubicado el file pointer en este momento, suponga que se quiere
cambiar su valor para que salte al final del archivo. Podemos hacerlo así:
m.seek(0, io.SEEK_END)

c.) Suponga que el file pointer en este momento está apuntando al byte 7 del archivo (el
valor actual del file pointer es 7), y suponga que se quiere cambiar su valor para que pase
a apuntar al byte 4. Podemos hacerlo así:
m.seek(-3, io.SEEK_CUR)

d.) Lo anterior también podría resolverse así:


m.seek(4, io.SEEK_SET)

Aunque el método seek() admite todas estas variantes, en la práctica lo más común es que se utilice siempre con
el segundo parámetro valiendo io.SEEK_SET, si se usa io.SEEK_SET, entonces el valor pasado como primer
parámetro es directamente el valor que tomará el file pointer y no se tiene que realizar en este caso ningún
cálculo auxiliar.

Al hacer esto, el file pointer queda


valiendo el número del primer
byte que está fuera del archivo

El uso del método seek() permite cambiar el valor del file pointer a voluntad del programador, de forma que
luego puede leer o grabar en cualquier posición del archivo sin tener que hacer un recorrido secuencial previo.
Ficha 23
Archivos: Gestión ABM
Gestión ABM es el nombre abreviado que suele darse a un programa completo cuyo objetivo es el de permitir
agregar registros a un archivo:

ABM

Alta de un Baja de un Modificación de


registro registro un registro

Bajas en un archivo de registros


El proceso de eliminación o baja de un registro en un archivo consiste en buscar el registro que se quiere
eliminar, y proceder a removerlo del archivo.
Baja Física: Se le llama baja física al proceso mediante el cual a través de una operación cualquiera se elimine
un registro de un archivo de forma que ese registro no ocupe mas lugar dentro de ese archivo.
Ej.: Cada registro de un archivo original (‘rb’) se lee hasta el final y se graba en un archivo temporal (‘wb’) salvo el
registro que se quiere eliminar, a través de un ciclo. Cuando el ciclo termine, el archivo temporal será una copia
del original sin el registro que se quería borrar. Lo único que queda es poner el mismo nombre que tenía el
archivo original y borrar del sistema de archivos (file system) del disco el archivo original.
Ventaja: Se elimina realmente el registro disminuyendo el archivo original.
Desventaja: Lleva mucho tiempo el proceso ya que se leen todos los registros.
Baja o marcado lógico: en vez de eliminar físicamente un registro, se lo marca usando un campo especialmente
definido a modo de bandera: si este campo vale False, significa que el registro está marcado como eliminado y
por lo tanto no debe tenerse en cuenta en ninguna operación que se realice sobre el archivo.
Cuando un registro nuevo se graba en el archivo, se hace de forma que ese campo de marcado lógico valga True,
y sólo se cambia ese valor cuando se desea eliminar el registro. De esta forma, el proceso de baja lógica consiste
en buscar el registro con la clave dada usando un ciclo, leerlo con pickle.load(), cambiar el campo de marcado
lógico a False, y volver a grabar el registro en el mismo lugar original usando pickle.dump().
Ventaja: No se pierde tiempo leyendo uno por uno de los registros.
Desventaja: Los registros siguen ocupando lugar físico en el archivo.
En la practica se aplica un esquema combinado: cuando se requieren hacer bajas en el momento se aplica un
proceso de baja lógica para no perder tiempo, y luego en algún momento no critico (cierre o apertura) el
encargado de mantenimiento del sistema aplica un proceso de bajas físicas a los registros marcados
eliminándolos de una sola pasada. Este proceso se lo denomina depuración de un archivo o también
compactación u optimización del espacio físico del archivo.
La técnica para realizar bajas lógicas realmente permite ganar tiempo si el archivo está organizado de forma
que permita accesos rápidos sus registros individuales. Esto puede lograrse con métodos de organización muy
conocidos como la organización de claves para acceso directo, la indexación, o la búsqueda por dispersión
(llamada más comúnmente hashing).
Altas en un archivo de registros
La operación de agregar un nuevo registro se designa como alta de un registro. El proceso consiste buscar en el
archivo la clave o valor que se usa para identificar a un registro, si no esta ya cargado se cargan el resto de los
datos y carga un registro nuevo poniendo el campo activo (True) y agregar el registro al final del archivo.
Como en el archivo se debe buscar un registro con la clave dada y eso requiere lecturas, y además el nuevo
registro debe grabarse al final del archivo, entonces el modo de apertura debe ser "a+b" (lo cual también
garantiza que el archivo será creado si no existía). Si el archivo admite registros repetidos no es necesario la
fase de búsqueda solo se debe abrir el archivo en modo ‘ab’

Modificaciones y listados en un archivo de registros


El proceso es similar al de la baja lógica: se carga por teclado el valor de la clave del registro que se quiere
modificar. Se busca dicho registro en el archivo usando un ciclo y leyendo y comparando uno por uno. Si se
encuentra un registro cuya clave coincida con la buscada y tenga en True en el campo de marcado lógico, se
muestran los campos del mismo por pantalla, y con un pequeño menú de opciones se pide al operador que elija
los campos que quiere modificar, cargando por teclado los valores nuevos.
A diferencia de la baja lógica se cambia sólo el valor del campo de marcado lógico, y en la modificación se
cambia el resto de los campos (y no el campo de marcado lógico).
El problema es que el nuevo registro podría quedar con un tamaño en bytes diferente al del registro original (por
ejemplo: si el nombre original era "Ana" y se cambia por "Ana María", el nuevo registro tendrá por lo menos 6
bytes más que el original). Y al grabar el nuevo registro para que pise al original, se perderían bytes del registro
siguiente, haciendo que el archivo quede ilegible desde esa posición en adelante.
Una solución al problema anterior, es forzar a que todos los registros tengan el mismo tamaño en bytes cuando
se graban en el archivo. En memoria pueden ser de diferente tamaño, pero al llegar el momento de su grabación
con pickle.dump(), asegurarse de que el tamaño en bytes sea uniforme.
Si un campo es de tipo cadena, se puede hacer que al momento de grabar esa cadena se ajuste su tamaño para
cierta cantidad prefijada de caracteres. En principio eso puede hacerse con el método ljust() provisto por la clase
str. Si nombre es un campo de tipo cadena, y queremos que se ajuste a 30 caracteres de largo (llenando lo que
falte con blancos), podemos hacerlo así:

Si el método ljust() se invoca pasando sólo un parámetro numérico (como en el ejemplo), entonces se asume que
el relleno debe hacerse con espacios en blanco. Opcionalmente, se puede enviar un segundo parámetro
indicando cuál es el caracter de relleno que debería usarse.

Si un campo es de tipo int, Python asigna un número de bytes diferente de acuerdo a ese valor. Para evitar
problemas de registros de distinto tamaño, una idea es convertir ese campo a una cadena de tamaño fijo (con
str()), y luego volver a convertirlo a entero (con int()).
Los campos de tipo float no presentan problemas, porque para ese tipo Python asigna siempre la misma cantidad
de bytes.
En general, la operación de modificación del contenido de un registro se hace sobre los campos que no son la
clave del registro o no forman parte de la clave del registro. Si se desea modificar el campo clave, debe hacerse
primero la baja del registro, y luego volver a darlo de alta con la nueva clave.
Búsqueda de un Registro
Este tipo de programas puede incluir muchas otras opciones de gestión, la mayoría en forma de tareas de
búsqueda y/o de listados de contenido.
Un proceso de listado completo es aquel que simplemente muestra el contenido completo del archivo, y se
plantea en forma simple: el archivo se abre en modo de solo lectura (rb), con un ciclo se leen uno por uno sus
registros y se muestran en pantalla a medida que se leen.
Por otra parte, un listado parcial (también conocido como listado con filtro) es aquel en el cual se recorre y se
lee todo el archivo, pero sólo se muestran en pantalla los registros que cumplen con cierta condición (por
ejemplo, mostrar sólo los registros de los alumnos cuyo promedio sea mayor o igual a 7).
Funcion de búsqueda:
La idea es que la función tome como parámetro la clave de búsqueda (el legajo del alumno en nuestro caso) y el
archivo en el que se debe buscar, pero ya abierto y en un modo que permita lecturas. La función debe posicionar
el file pointer al inicio del archivo, y desde allí comenzar a leer cada registro, prestando atención sólo a aquellos
que no estén marcados como borrados.
Si encuentra un registro con la clave buscada, se debe detener la búsqueda, volver a colocar el file pointer donde
estaba cuando la función fue invocada, y retornar la dirección del registro encontrado (o sea, el número del byte
donde ese registro comienza)

El método strip() que elimina los espacios en blanco que ese nombre pudiese tener al principio o al final.

Ficha 25
Archivos de Texto
Un archivo de texto es aquel en que todos sus bytes se interpretan como caracteres visualizables (en principio,
aquellos cuyo valor ASCII está entre 32 y 126). Python usa el estándar de codificación de caracteres UTF-8, que
incluye al sistema ASCII como subconjunto.
Un detalle a manejar, es la forma en que se trata el caracter de fin de línea, usado para indicar a los programas
que manejan el archivo que deben saltar una línea en la pantalla al mostrar su contenido. En las plataformas
Unix y Linux, es el caracter de control "\n", pero en Windows es el par "\r\n" (un retorno de carro seguido de un
salto de línea).
Cuando en Python se lee un archivo de texto la acción por default es convertir en la cadena leída los caracteres
de fin de línea que aparezcan (el \n de Unix o el par \r\n de Windows) simplemente en un \n.
De esta forma, cualquiera sea la plataforma de origen del archivo, los saltos de renglón en la pantalla serán
interpretados en forma consistente. Y en forma recíproca, cuando se graba en un archivo de texto la acción por
default es convertir los caracteres \n que las cadenas a grabar contengan y grabar en el archivo los caracteres
que correspondan según el sistema operativo huésped en ese momento.
Como esta acción eventualmente modifica el contenido original del archivo, el programador debe tener cuidado
que el archivo que está procesando es de texto, y abrirlo como tal (en modo 't' o no colocando ni la 't' ni la 'b' (que,
equivale por default a colocar una 't').
Si se abre en modo texto un archivo pensado como binario (por ejemplo, un archivo .exe o un .jpg o un .pdf), estos
cambios podrían corromper el contenido del archivo, perdiendo datos o haciendo imposible abrirlo en forma
normal. Si el archivo no es de texto, entonces, debería abrirse colocando la 'b' en el modo de apertura, lo cual
desactiva el reemplazo de caracteres de salto de línea.

Uso de archivos de texto en Python


La forma abrir y usar un archivo de texto en Python es esencialmente la misma que la explicada para archivos
binarios. Un archivo de texto también debe abrirse para poder operar con él, y la apertura se hace con la misma
función open().
Una vez que un archivo de texto se abre, el file objet creado por open() provee métodos directos para grabar o
leer cadenas hacia o desde ese archivo (no es necesario el módulo pickle). Si m representa al archivo abierto con
open(),

Método Acción

m.write(cad) Graba la cadena cad en el archivo, sin agregar un salto de línea al final de
la misma (puede agregarlo el programador).

cad = m.read() Lee y retorna el contenido completo del archivo, como una única cadena.

cad = m.readline() Lee una cadena simple desde el archivo. Comienza en la posición del file
pointer y termina al encontrar un salto de línea o el final del archivo.
Mantiene el salto de línea en la cadena retornada.

cad = Lee todas las líneas del archivo, y retorna una lista o arreglo con todas
m.readlines() ellas (una por cada casilla del arreglo). Mantiene los saltos de línea al final
de cada cadena leída.

Ej.:
write()

Este método no agrega un salto de línea al


final de la cadena, por lo cual el
programador debe incluir un caracter '\n'
explícitamente si lo desea

El método close() se usa para cerrar la conexión con el archivo y liberar cualquier recurso que estuviese
asociado a él. Luego de invocar a close(), la variable que representaba al archivo queda indefinida. Además, que
en Python los archivos son cerrados automáticamente cuando la variable usada para accederlos sale del ámbito
en que fue definida. Por lo tanto, en la función anterior no es estrictamente necesario invocar a close().
read()

Se puede pasar como parámetro un número entero


positivo, en cuyo caso read(n) leerá y retornará n bytes
desde el archivo. Los caracteres '\n' que el archivo
pudiera tener se preservan y se copian en la cadena
retornada.

readline()

Si no puede leer una nueva cadena desde el archivo,


por ejemplo por haber llegado ya al final del mismo, el
método readline() retorna una cadena vacía ('')

Si el archivo contuviese una cantidad mucho mayor de líneas y/o no se conociese esa cantidad, entonces una
forma alternativa y simple de leer el archivo completo, línea por línea, consiste en usar un ciclo for que itere
sobre el contenido del file object:

Para tener un mejor control, se puede implementar un ciclo que vaya leyendo el archivo con readline(),
verificando si esa función retornó o no la cadena vacía (indicador de que se ha llegado al final del archivo).
Además, se puede hacer un útil proceso sobre la cadena leída, para eliminar el caracter de salto línea del final
mediante un corte de índices.
Si la variable line es una cadena de
caracteres, la expresión if not line: equivale
a la expresión if line == "": con lo cual la
función mostrada está detectando el final del
archivo y cortando el ciclo con un break.

line = line[:-1] está tomando todo


el contenido de la cadena line
salvo el último caracter, y
asignando la cadena cortada en
line. El resultado de esto es que
se elimina el caracter '\n' que line
tenía al final
readlines() Se puede invocar al método
pasándole como parámetro un
valor numérico, en cuyo caso
readlines(n) intentará recuperar
hasta n bytes del archivo.

Reposicionamiento del file pointer en un archivo de texto en Python


el método tell() retorna
un número entero con el
valor que en ese
momento tenga el file
pointer del archivo, en la
posición cero

Recuerde que si el file pointer está ubicado al final de un archivo entonces el valor retornado por tell() indica el
tamaño en bytes del archivo.
El método seek() permite que el programador cambie el valor del file pointer para permitir luego grabaciones o
lecturas en forma directa en cualquier byte de un archivo, y esto vale también para archivos de texto.
seek() en general recibe dos parámetros: el primero indica cuántos bytes debe moverse el file pointer, y el
segundo indica desde donde se hace ese salto (el valor io.SEEK_SET = 0 indica saltar desde el principio del
archivo, el valor io.SEEK_CUR = 1 indica saltar desde donde está el file pointer en ese momento y el valor
io.SEEK_END = 2 indica saltar desde el final). El valor por default del segundo parámetro es io.SEEK_SET = 0, por
lo cual por omisión se asume que los saltos son desde el inicio del archivo
Pero cuando se trata de un archivo de texto, hay algunas restricciones al uso de seek() en Python:
Solo está permitido hacer saltos desde el comienzo del archivo: el segundo parámetro de seek() debe ser
siempre io.SEEK_SET (y por lo tanto, el valor del primer parámetro será siempre el valor que quedará valiendo el
file pointer).
Si el segundo parámetro es io.SEEK_CUR o io.SEEK_END, entonces el primer parámetro debe ser
obligatoriamente cero. Si se usó io.SEEK_END con el primer parámetro en 0, el file pointer se moverá al final del
archivo. Y io.SEEK_CUR con el primer parámetro en 0, no cambiará el valor del file pointer. Cualquier otra
combinación provoca un error de ejecución.
No confundir tamaño del archivo en bytes, con cantidad de caracteres del archivo

En Python se usa el estándar UTF-8 para representar caracteres, y en ese modelo un caracter puede ser
representado con 1, 2, 3 o 4 bytes dependiendo del caracter. El método seek() permite saltar a un byte
determinado, y NO a un caracter específico.
Existen otros métodos y atributos (o campos) en la variable file object que se crea con open().

Procesamiento de archivos de texto que contienen secuencias numéricas


Muchas veces se tienen archivos de texto que contienen series de números expresados como cadenas. Es común
que en estos casos cada línea contenga un "número" y el salto de línea que separa a un número del siguiente.
Cuando se necesita procesar un archivo así, la idea es leer las cadenas que representan números en el archivo,
convertirlas a números en el formato correcto (int o float), almacenar esos números en un arreglo (por ejemplo),
y finalmente retornar el arreglo (u operar con él).
Guardar números como cadenas en un archivo, se hace (por ejemplo) para evitar problemas de incompatibilidad
de formatos numéricos (lo que puede pasar si distintos programadores usan lenguajes diferentes para procesar
el mismo archivo). El formato de texto también tiene diversas variantes, pero es siempre más sencillo
compatibilizar formatos de texto que numéricos, sobre todo si el archivo de texto se genera usando un juego de
caracteres estándar (ASCII, UTF-8, etc.)
Ficha 26
Recursividad
Una definición se dice recursiva si el objeto o concepto que está siendo definido aparece a su vez en la propia
definición.
Una frase es un conjunto de palabras que puede estar vacío, o bien puede contener una palabra seguida a su vez
de otra frase.
Una definición recursiva bien planteada, debe cumplir con ciertos requisitos:
La definición debe agregar conocimiento respecto del objeto definido (no basta con que ese objeto sea nombrado
en la misma definición, si no se agregan elementos que permitan construir el concepto). Ejemplo de definición
recursiva que no agrega conocimiento: Una frase es una frase
La definición debe evitar la recursión infinita, que surge cuando no incluye elementos que permitan cerrarla
lógicamente y el proceso implicado no termina nunca de invocarse a sí mismo. Ejemplo: Una frase es un conjunto
que consta de una palabra seguida a su vez de una frase. Al no incluir la posibilidad de que una frase esté vacía,
esta definición sugiere un proceso infinito...

Programación recursiva
Prácticamente todos los lenguajes de programación modernos soportan la recursividad, a través del planteo de
subrutinas recursivas. En Python, la idea es que un algoritmo recursivo puede implementarse a través de
funciones de comportamiento recursivo. En términos muy básicos, una función recursiva es una función que
incluye en su bloque de acciones una o más invocaciones a sí misma.
Una función recursiva es aquella que se invoca a sí misma una o más veces.

Error: una vez invocada esta


función provoca una cascada
infinita de auto-invocaciones
def factorial(n):
return factorial(n)

Estas deberían agregar conocimiento respecto del concepto definido, y evitar la recursión infinita incluyendo una
condición que corte el proceso recursivo.
agrega conocimiento, pero
cae en recursión infinita

La recursión infinita se evita


considerando que si n == 0, entonces
el factorial de n es 1. Una simple
condición en la función (condición de
corte)
Cuando una función es invocada (recursiva o no), se le asigna un pequeño bloque de memoria en un segmento de
la memoria principal conocido como Stack Segment (o Segmento Pila). En ese bloque, la función almacena sus
variables locales, y su dirección de retorno (la dirección de memoria a la que debe volver cuando termine).
Por lo tanto, una función (recursiva o no) ocupa cierta cantidad de memoria extra cuando se activa. Si una
función a su vez invoca a otra, esa otra también recibe un bloque en el Stack, que se ubicará encima del bloque
anterior, en modo LIFO (y de ahí el nombre de Stack Segment).
La última función en invocarse estará en la cima del Stack, y la función que la invocó estará inmediatamente
debajo de ella en el Stack. Como el Stack es un segmento de poco tamaño, una secuencia muy larga de funciones
que se invoquen entre ellas podría desbordarlo (lo que se conoce como Stack Overflow) haciendo que el
programa se interrumpa

Consideraciones generales
Para evitar la recursión infinita la clave de este paso es tener en cuenta que lo que se busca determinar con esa
condición es si se ha presentado lo que se conoce como un caso base o un caso trivial para el problema: un caso
en el que la recursión no es necesaria y puede resolverse en forma directa
La complejidad aparente y la estructura del código fuente de un programa o función es otro de los elementos que
se tienen en cuenta para hacer un análisis comparativo entre dos o más soluciones propuestas para un mismo
problema.
Los tres factores generales de análisis comparativo entre algoritmos (consumo de memoria, tiempo de ejecución
y complejidad de código fuente)
Tanto para el cálculo del factorial como para el cálculo del término enésimo de Fibonacci, tenemos dos
funciones: una iterativa y otra recursiva... ¿Cuál de ambas versiones (en cada caso) será la más eficiente?
Ambas versiones del factorial ejecutan en O(n) unidades de tiempo. Pero la versión recursiva usa O(n) bloques de
memoria de stack, mientras que la iterativa usa un número constante de variables para todo el cálculo, por lo
que su consumo de memoria es O(1). La versión iterativa es entonces la más conveniente.

La Sucesión de Fibonacci
En cuanto a la memoria, la versión iterativa insume un puñado fijo de variables locales, por lo que tenemos O(1)
unidades de memoria. La versión recursiva tiene que alojar un bloque en el stack por cada caja del diagrama.
pero el detalle es que nunca están todas juntas: el máximo nivel de ocupación es que el corresponde a la rama
más larga del árbol anterior (que es la rama extrema izquierda, con 6 cajas si n=6)... Por lo tanto, la versión
recursiva requiere en un momento dado un máximo de O(n) unidades de memoria. Y hasta aquí es preferible la
iterativa.
En cuanto al tiempo, la iterativa termina en O(n) unidades de tiempo, pero vimos que la recursiva es
sorprendentemente lenta para valores de n tan pequeños como 35 o 40... Y ni hablar de 60 o 100
La versión recursiva aplica dos llamadas recursivas para cada cálculo, y hay que esperar a que TODAS terminen
para llegar al resultado. La cantidad de cajas a ejecutar nos da una medida del tiempo a esperar
La cantidad de cajas crece con el número de niveles, y la progresión esconde un crecimiento exponencial O(2 n).
Por lo tanto, la versión recursiva tiene un tiempo de ejecución que la hace directamente inaplicable a partir de
valores pequeños de n como 35 o 45. Y para valores como 60 o más, la demora es tan asombrosa que toda la vida
del universo podría no alcanzar para llegar al resultado final.
La recursividad es muy útil para el planteo de algoritmos, pero debe ser usada con cuidado y con conocimiento
adecuado en cuanto a la forma de estimar el uso de recursos de tiempo y memoria.
En general, desde el punto de vista de la complejidad del código fuente, la recursión permite escribir programas
más compactos, más simples de comprender, y más consistentes con la definición formal del problema que en un
planteo iterativo. Pero si se toma a la ligera el consumo de tiempo y memoria usada, el programa obtenido podría
ser inaceptable en la práctica.

Ficha 27
Divide y vencerás
Una de las áreas en que la recursión es recomendable es el procesamiento de estructuras de datos no lineales
como los árboles o los grafos. Y otra de esas áreas es la generación y tratamiento de figuras y gráficos fractales
Una figura fractal es aquella que se compone de versiones más simples y pequeñas de la misma figura original
Python provee numerosos y potentes mecanismos para el diseño de ventanas y componentes gráficos
El primer elemento que debe dominarse es el esquema de coordenadas de pantalla que Python (y todo otro
lenguaje) supone para la gestión de gráficos. El sistema de coordenadas de Python permite localizar a cada
punto de luz o pixel de la pantalla en forma directa Por defecto, el punto superior izquierdo dentro del área de
dibujo de una ventana o un lienzo gráfico (conocido como un canvas) es el origen de coordenadas de ese
contenedor gráfico (o sea, el punto de coordenadas (0, 0) del contenedor).

Aplicaciones de la recursividad: El algoritmo Quicksort


El algoritmo de ordenamiento Quicksort (Hoare, 1960) es un algorimo muy rápido, cuyo tiempo promedio es
O(n*log(n)). En esencia, se basa en modificar aspectos poco eficientes del algoritmo de Intercambio Directo (o
Burbuja), en el que algunos elementos viajan más rápido que otros hacia sus posiciones finales.
La idea es recorrer el arreglo desde los dos extremos. Se toma un elemento pivot (que suele ser el valor ubicado
al medio del arreglo pero puede ser cualquier otro). Luego, se recorre el arreglo desde la izquierda buscando
algún valor que sea mayor que el pivot. Al encontrarlo, se comienza una búsqueda similar pero desde la derecha,
ahora buscando un valor menor al pivot. Cuando ambos hayan sido encontrados, se intercambian entre ellos y se
sigue buscando otro par de valores en forma similar, hasta que ambas secuencias de búsqueda se crucen entre
ellas. De esta forma, se favorece que tanto los valores mayores ubicados muy a la izquierda como los menores
ubicados muy a la derecha, viajen rápido hacia el otro extremo.
Al terminar esta pasada, se puede ver que el arreglo no queda necesariamente ordenado, pero queda claramente
dividido en dos subarreglos: el de la izquierda contiene elementos que son todos menores o iguales al pivot, y el
de la derecha contiene elementos mayores o iguales al pivot Por lo tanto, ahora se puede aplicar exactamente el
mismo proceso a cada subarreglo, usando recursividad. El mismo método que particionó en dos el arreglo
original, se invoca a sí mismo dos veces más, para partir en otros subarreglos a los dos que se obtuvieron recién.
Con esto se generan cuatro subarreglos, y con más recursión se procede igual con ellos, hasta que sólo se
obtengan particiones de tamaño uno. En ese momento, el arreglo quedará ordenado.
La estrategia Divide y Vencerás para resolución de problemas
Como toda estrategia general de resolución de problemas, la técnica divide y vencerás puede aplicarse en
ciertos problemas (cuya estructura interna admita la división en subconjuntos de tamaños similares) para lograr
soluciones con mejor o mucho mejor tiempo de ejecución en comparación a soluciones intuitivas de fuerza bruta,
que suelen consistir en explorar todas y cada una de las posibles combinaciones de procesamiento de los datos
de entrada, pero de tal forma que la cantidad de operaciones a realizar aumenta de manera dramática a medida
que crece la cantidad n de datos
La idea general aplicada en el Quicksort, se conoce como Estrategia Divide y Vencerás (DyV). El lote de n datos se
procesa y se divide (normalmente en dos). Cada partición (recursivamente) se vuelve a procesar, se vuelve a
dividir, y recursivamente se procesa cada nueva partición.
Cuando ya no es posible seguir dividiendo (particiones de tamaño 1), se detiene el proceso. Y la unión de todas las
particiones es el resultado general buscado (que se corresponde con la idea de vencerás...)
Muchos problemas pueden resolverse con algoritmos DyV. Si el proceso adicional a realizar en cada partición
ejecuta en tiempo t(n) = O(n), y se aplica la idea de ir dividiendo por 2, y además se aplica una recursión a cada
nueva partición, entonces el esquema general es de alguna de las formas siguientes:

La estrategia divide y vencerás se aplicó para diseñar un algoritmo de ordenamiento y al analizarlo hemos
obtenido un tiempo de ejecución promedio de O(n*log(n) ¿Toda vez que se aplique la estrategia divide y vencerás
se obtendrá una solución con tiempo de ejecución O(n*log(n)) para el caso promedio o para el peor caso?sí
siempre y cuando el algoritmo analizado tenga la misma estructura que el algoritmo quick()
En cada nivel del árbol de invocaciones recursivas, se activa un proceso de tiempo de ejecución lineal (O(n)). Por
lo tanto, el tiempo completo de ejecución será igual al número de niveles k de ese árbol (la altura) por O(n).
Si observamos, partiendo de n = 8 = 23 casillas en el vector, el número de niveles del árbol (sin contar el último en
el que cada partición es de tamaño 1), es k = 3, y ese número surge de la cantidad de veces que se puede dividir n
(y sus cocientes parciales) en 2, hasta que el cociente parcial sea 1. Pero ese número es el exponente al que se
debe elevar el 2 para volver a obtener n = 8... que como sabemos es equivalente al logaritmo en base 2 de n. En
resumen: k = log2(n).
Por lo tanto, el tiempo completo de ejecución de un proceso DyV basado en el esquema básico que se sugirió
aquí, es t(n) = O(k * n) = O(log2(n) * n) = O(n * log(n))
El algoritmo Quicksort se ajusta al esquema de DyV que hemos mostrado, y por lo tanto Quicksort ejecuta en
tiempo O(n*log(n))... pero ese rendimiento corresponde al caso promedio (y NO al peor caso): no todas las
particiones en Quicksort serán del mismo tamaño, y no necesariamente la cantidad de niveles del árbol será
igual log(n)...
Podemos esperar que en promedio, las particiones tengan tamaño similar, y eso en promedio compense a la
larga la cantidad de niveles. Pero podría ocurrir un peor caso inesperado...
Si cada vez que se toma un pivot, en cada una de todas las particiones, ese pivot fuese el menor (o el mayor) de
esa partición, entonces al pivotear, ese menor/mayor quedaría en un extremo del vector (en una partición de
tamaño 1), y todos los demás valores quedarían en otra partición de tamaño n-1 (y no n/2)... El triste resultado,
sería que la cantidad de niveles del árbol de invocaciones sería igual a n, y no a log(n)... Y en ese caso, el tiempo
de ejecución (peor caso...) sería un horrible t(n) = O(n * n) = O(n2)...
La forma en que se elije el pivot en cada partición aumenta o disminuye las probabilidades de que Quicksort
caiga en ese peor caso...
A veces se comete el error de seleccionar como pivot al primer elemento o al último elemento de cada partición.
Pero esta es una muy mala idea, pues si el vector estuviese ya ordenado, o casi ordenado, el algoritmo demoraría
t(n) = O(n2) unidades de tiempo en hacer casi nada (solo comparaciones, con pocos o ningún intercambio).
La idea de tomar como pivot al central de cada partición no es mala, pero se considera pasiva: el algoritmo no
hace nada para evitar tomar siempre al menor (o mayor) si se tuviese la mala fortuna de que ese menor/mayor
estuviese siempre al centro.
La mejor estrategia conocida para tomar pivotes en Quicksort, es la que se conoce como Mediana de Tres, que es
una estrategia activa: modifica el contenido de la partición para disminuir la probabilidad de tomar siempre el
menor/mayor.

Mediana de Tres
En la implementación del Quicksort por Mediana de Tres, en cada partición se toma el valor del extremo
izquierdo, el valor del extremo derecho y el valor central de la partición, se los ordena entre ellos y se toma
como pivot al que finalmente haya quedado en la casilla central. De esta forma, se garantiza que nunca se
tomará como pivot al mayor o al menor, evitando el peor caso antes mencionado
También se suele aconsejar que la mediana de tres se tome entre tres elementos seleccionados aleatoriamente
dentro de cada partición, lo cual efectivamente lleva a tiempos de ejecución ligeramente mejores.

Al tomar tres elementos de la partición y


ordenarlos, se garantiza que no se tomará
nunca el menor ni el mayor de esa partición
(a menos que todos sean iguales) Si la
partición tiene n elementos, este proceso
toma 3 de esos n, y siempre tres, por lo que
el tiempo para ejecutarse no depende de n.
No importa cuán grande sea n, el tiempo de
este proceso será siempre el que tome
hacer estas tres condiciones, por lo que
queda t(n) = O(1) (constante).
Ficha 28
Algoritmos Ávidos, Programación Dinámica y Backtracking
A lo largo de los años los investigadores y los especialistas en el área de los algoritmos han estudiado y
propuesto diversas técnicas básicas que ayudan a plantear algoritmos para situaciones específicas. Estas
técnicas no implican una garantía de éxito, sino simplemente un punto de partida para intentar encontrar una
solución frente a un problema particular.
Además, si lo logra, nada garantiza que la solución encontrada sea eficiente, pero incluso así se tienen elementos
para comenzar a trabajar y al menos poder descartar algunos caminos.

Técnicas o estrategias de planteo de algoritmos

Fuerza bruta: Consiste en explorar y aplicar sistemáticamente, una por una de las posibles combinaciones de
solución para el problema dado (ej: el algoritmo de ordenamiento de Selección Directa). Por lo general, los
algoritmos obtenidos por fuerza bruta resultan intuitivamente simples de comprender e implementar ya que la
esencia del planteo consiste justamente en no ahorrar pasos.
Aunque para casos especiales de esos problemas se aplican estrategias de optimización que permiten mejorar el
rendimiento de la solución. Ejemplo: El problema del viajante o Vendedor Ambulante

Recursión: Es la propiedad que permite que un proceso se invoque a si mismo una o más veces como parte de la
solución. Si un problema puede ser entendido en base a versiones más pequeñas y manejables de si mismo,
entonces un planteo recursivo puede ayudar a lograr un algoritmo que normalmente será muy claro para
entender e implementar, al costo de utilizar cierta cantidad de memoria adicional en el segmento de stack del
computador (y algo de tiempo extra para gestionar el apilamiento en ese stack). En muchos problemas ese costo
no es aceptable (factorial, Fibonacci), pero en otros la recursión permite el planteos claros y concisos, frente a
planteos no recursivos extensos, intrincados y sumamente difíciles de mantener frente a cambios en los
requerimientos. Casos en que la recursión es apropiada, se dan en algoritmos para generar gráficas fractales o
en algoritmos de inserción y borrado en árboles de búsqueda equilibrados.

Vuelta Atrás (Backtracking): Es una técnica que permite explorar en forma incremental un conjunto de
potenciales soluciones parciales a un problema, de forma que si se detecta que una solución parcial no puede ser
una solución válida, se la descarta junto a todas las candidatas que podrían haberse propuesto a partir de ella.
Típicamente, se implementa mediante recursión generando un árbol de invocaciones recursivas en el que cada
nodo constituye una solución parcial. Cuando es aplicable, el backtracking suele ser más eficiente que la
enumeración por fuerza bruta de todas las soluciones, ya que con backtracking pueden eliminarse muchas
soluciones sin tener que analizarlas. Se usa en problemas que admiten la idea de solución parcial, siempre y
cuando se pueda comprobar en forma aceptablemente rápida si una solución parcial es válida o no. Ejemplos:
Problema de las Ocho Reinas, Palabras Cruzadas, Sudoku y en general, en problemas de optimización
combinatoria.
Algoritmos Ávidos o Devoradores (Greedy Algorithms): Un algoritmo ávido es aquel que aplica una regla
intuitivamente válida en cada paso local del proceso, con la esperanza de obtener finalmente una solución global
óptima que resuelva el problema original. La ventaja de un algoritmo ávido (cuando es correcto) es que por lo
general lleva a una solución simple de entender, muy directa de implementar y razonablemente eficiente. Pero la
desventaja es que como no siempre lleva a una solución correcta, se debe realizar una demostración de la
validez del algoritmo que podría no ser sencilla de hacer. Ejemplos: Algoritmos de Prim y de Kruskal para el
Árbol de Expansión Mínimo de un Grafo, o el Algoritmo de Dijkstra para el Camino más Corto entre Nodos de un
Grafo.
Divide y vencerás: Consiste en tratar de dividir el lote de datos en dos o más subconjuntos de tamaños
aproximadamente iguales, procesar cada subconjunto por separado y finalmente unir los resultados para
obtener la solución final. Normalmente, se aplica recursión para procesar a cada subconjunto y el proceso total
podrá ser más o menos eficiente en cuanto a tiempo de ejecución dependiendo de tres factores: la cantidad de
invocaciones recursivas que se hagan, el factor de achicamiento del lote de datos (por cuanto se divide al lote en
cada pasada) y el tiempo que lleve procesar en forma separada a un subconjunto. Ejemplos: Algoritmos Quicksort
y Mergesort para ordenamiento de arreglos.

Programación Dinámica: Esta técnica sugiere almacenar en una tabla las soluciones obtenidas previamente para
los subproblemas que pudiera tener un problema mayor, de forma que cada subproblema se resuelva sólo una
vez y luego simplemente se obtengan sus soluciones consultando la tabla si esos subproblemas volvieran a
presentarse. Esto tiene mucho sentido: en muchos planteos originalmente suele ocurrir que al dividir un
problema en subproblemas se observe que varios de estos últimos se repiten más de una vez, con la
consecuente pérdida de tiempo que implicaría el tener que volver a resolverlos. Ejemplos: Problema de
Alineación de Secuencias (encontrar la mínima cantidad de cambios que se requieren para que una secuencia de
entrada se convierta en otra).
Randomización (Algoritmos de Base Aleatoria): Para algunos problemas se suele intentar plantear algoritmos
que ya no sean deterministas (la misma entrada produce la misma salida, sino randomizados o de base aleatoria
(la misma entrada podría producir salidas diferentes ya que el siguiente paso a aplicar surge de algún tipo de
selección aleatoria). Y está claro que si interviene el azar, entonces es posible que el algoritmo no llegue
eventualmente a una solución correcta. La idea es que aplicar esta técnica debe implicar alguna ganancia en
eficiencia, se debe poder calcular la probabilidad de que algoritmo falle y diseñarlo de forma esa probabilidad
sea realmente muy baja, y finalmente sería deseable que el algoritmo sea simple de implementar).Ejemplo:
Algoritmo de Karger para el Problema del Corte Mínimo en un Grafo.

Conceptos básicos sobre Algoritmos Ávidos y Programación Dinámica:


Problema del Cambio de Monedas
Se trata de plantear un programa que pueda calcular la mínima cantidad de monedas en la que puede cambiarse
una cierta cantidad x de centavos, conociendo los valores nominales de las monedas disponibles. Supongamos
que disponemos de un conjunto de monedas "coins" de 1, 5, 10 y 25 centavos. Nos dan una cantidad x de
centavos, y nos piden calcular la mínima cantidad de monedas de coins en la que puede expresarse (o
cambiarse) el valor x.
Así, si x = 25 entonces la mínima cantidad de monedas de coins = {1, 5, 10, 25} que podemos usar es 1, ya que
disponemos de una moneda de exactamente 25 centavos. Si x = 31, entonces la mínima cantidad de monedas es 3:
una de 25, una de 5 y una más de 1 centavo.
Se pide explorar distintas posibilidades algorítmicas para este problema, y plantear los programas
correspondientes.
Solución con Algoritmo Ávido
Un algoritmo ávido (que no siempre funciona) para este problema, consiste en elegir sistemáticamente la
moneda de mayor valor, devolver tantas de ella como se pueda, y luego continuar así con las monedas de mayor
valor que siguen hasta cubrir el valor pedido. Así, si el conjunto de valores de monedas disponible es coins = [1, 5,
10, 25] y el valor a cambiar es x = 63, elegimos dos veces la moneda de 25, una vez la de 10 y tres la de 1, lo cual
da un total de 6 monedas (que efectivamente, es la mínima cantidad de monedas para llegar a cubrir x = 63).
Además, es exigible que la moneda de valor 1 exista para que el problema tenga solución, pues de otro modo
habría valores de x que serían imposibles de cambiar. Si las monedas disponibles son las que hemos supuesto
para el conjunto coins, se puede probar que esta forma de trabajar resolverá siempre el problema en forma
correcta, y cualquier conjunto de monedas para el cual la estrategia ávida funciona, se designa como base
monetaria canónica para el problema.
En todo caso, el problema con las reglas ávidas es que debemos demostrar que funcionan para toda combinación
posible de entradas, y esa demostración no siempre es simple de hacer.

El problema con esta idea, es que si la base de monedas contiene algún valor "extraño", entonces la
estrategia ávida podría no funcionar.

Supongamos que el conjunto coins fuese de la forma coins = [1, 5, 10, 23, 50] y queremos cambio de x = 69. La
estrategia ávida calcularía 7 monedas (una de 50, una de 10, una de 5 y cuatro de 1)... pero la solución
correcta es 3 (tres monedas de 23...)

En este caso, algoritmo ávido no funciona, y el conjunto coins mostrado NO ES entonces una base canónica.

Conceptos básicos sobre Backtracking


Se trata de una técnica de base recursiva mediante la cual se exploran todos los posibles caminos que pudieran
conducir a la solución de un problema, pero de forma tal que cuando se descubre que un camino no conduce a
una solución válida, se aprovecha el retorno (o vuelta atrás, o backtracking) de las instancias recursivas que se
hayan activado para "deshacer" los cambios que hayan llevado por ese camino incorrecto, e intentar la
exploración de otro camino con nuevos valores.
Es muy útil en problemas en los que las soluciones surgen de una o más combinaciones de los mismos datos de
entrada, ya que el proceso de prueba y error que permite realizar garantiza que el algoritmo no dejará afuera
ninguna posible solución sin explorar. Sin embargo, esto puede llevar a un número considerable o demasiado
elevado de invocaciones o instancias recursivas, aumentando el tiempo de ejecución y/o la cantidad de memoria
de stack requerida.
Esta técnica suele usarse con frecuencia en problemas de optimización combinatoria (encontrar la mejor forma
de combinar ciertos elementos), y muchos de esos problemas tienen que ver con juegos de ingenio .

Ejemplo: Problema de las Ocho Reinas

También podría gustarte