[MÚSICA DE FONDO]
DAVID: Muy bien.
Esto es CS50 y vamos a iniciar la lección 5.
Y quizás recuerden que en esta semana,
probablemente hemos enfrentado algo como esto.
Y si no tenemos esta experiencia desde la infancia,
a lo que nos referimos en esta serie de problemas
es a estas gafas, que no son gafas 3-D porque tienen ambos ojos rojos
o a veces solo son una gran pieza de plástico.
Pero con esto podemos mirar hacia arriba y ver
cuál se supone que es la respuesta correcta.
Esta es la alusión a la que nos referimos
y en la serie de problemas cinco que hicieron, el objetivo
es averiguar cómo implementaríamos este filtro rojo.
Pero para hacer eso, primero debemos entender
esta cosa, la cual a primera vista, parece bastante complicada.
Pero si ya nos sumergimos en este problema,
probablemente han rondado en nuestra mente, al menos
algunos de estos ámbitos, como el tamaño, el ancho
y la altura de la imagen, con lo cual esto debería ser un poco más
sencillo.
Pero para implementarlo, tenemos que lidiar con algo
llamado struct o una estructura.
En C, tenemos esta función, recuérdenla.
En realidad, la última vez no jugamos mucho con esto.
Pero lo hemos visto en medicina forense, o lo haremos pronto.
Aquí tenemos la definición de un student.
Hace décadas, cuando se inventó C, no anticiparon
la necesidad de un tipo de datos para los estudiantes.
Tenían int, char, float y double.
Pero como en Scratch, podemos inventar nuestros propios tipos de datos
y hacer nuestras propias piezas de rompecabezas, de la siguiente manera.
typedef para definir un tipo, struct para decir que viene una estructura.
¿Cuál es la estructura conocida como student?
Bueno, en este caso, decidí arbitrariamente que un student
tiene un name, un dorm y ambos serían cadenas.
Imaginemos poner otras cosas allí
como números ID, teléfonos, direcciones de correo electrónico o lo que sea
y podríamos combinar toda esta información en conjunto.
Echemos un vistazo rápido a cómo usaríamos un código como este.
Aquí hay un archivo llamado struct.h
Es algo común, pero no es necesario declarar las estructuras dentro
de un archivo que también comienza con .h, para que podamos compartirlo en múltiples
programas al igual que con los encabezados de otras bibliotecas.
Aquí quité esas ruedas de entrenamiento
donde string es una mentira blanca para el carácter asterisco.
Pero esta es la misma estructura de datos
y está en un archivo llamado struct. h.
Démosle un vistazo rápido a un programa que utiliza
esto en struct0.c. Veamos lo que hicimos aquí.
En struct0.c tenemos arriba algunos encabezados de archivos.
Pero también debemos incluir este encabezado de archivo para tener acceso
a este nuevo tipo de datos personalizado.
Luego haremos algunas cosas en main.
Primero le pediremos al usuario un número entero llamado enrollment.
Con suerte nos darán un número positivo.
Si recibimos un número como se espera en la línea 13,
¿En inglés qué hacemos aquí?
¿Cómo describiríamos lo que hace la línea 13 en este punto del término?
Alguien, ¿sí?
PÚBLICO: [INAUDIBLE]
DAVID: Sí, nos da un arreglo students de tamaño enrollment.
De modo que, aunque en la línea 11 y previamente no lo hicimos,
sabemos cuántos estudiantes necesitamos, sí en la línea 12 conseguimos
la respuesta, con enrollment.
En la línea 13 declaramos un arreglo mediante una variable que dice,
dame tantos elementos o estudiantes en mi arreglo para almacenar cosas.
Y en las líneas a continuación, procedemos de la siguiente manera.
Comenzamos a iterar sobre enrollment, desde cero hasta enrollment.
Y en cada iteración le pedimos al usuario el name y el dorm de student.
La parte derecha de esas dos líneas de código es bastante familiar,
llamamos a get_string.
Pero en el lado izquierdo, tenemos una sintaxis ligeramente nueva.
Tenemos students [ i ] el cual nos da a los estudiantes i-ésimo en el arreglo.
¿Pero cuál pieza de sintaxis tal vez nos brinca?
¿En especial si nunca antes hemos programado?
Y aún no hemos utilizado este símbolo en este contexto.
¿Qué se ve diferente?
Sí.
PÚBLICO: [INAUDIBLE]
DAVID: Sí, el .name.
Probablemente podamos inferir que .name y .dorm de algún modo acceden
al name y al dorm de student.
Y eso es todo lo que está sucediendo.
El operador punto le dice a la computadora, que entre en la estructura de student
en esa ubicación i-ésima y almacene la cadena que
regresa de get_string en esa variable.
Y hace lo mismo para dorm.
Es como tomar una estructura y luego mirar dentro de ella
y va de manera muy específica hacia uno de los elementos de la misma.
Nunca hemos necesitado este .operador porque en el pasado, cualquier arreglo,
o variable que tuvimos fue un string, un int o un float.
No hemos tenido nada en que profundizar.
Entonces, eso es todo lo que sucede allí.
Encapsulamos, el interior del structure, el name y el dorm de un estudiante.
Y esta última parte es sólo un printf
de esa misma información.
Sólo imprimimos, así-y-así es en tal-y-tal dorm
pasando esas dos cadenas, usando nuestro conocido %s
Ahora, este programa al final del día es un poco inútil
porque le indicamos al usuario algunos names y dorms de estudiantes,
los imprimimos y luego los desechamos para siempre.
Eso no es tan útil para un programa a largo plazo.
Tenemos un truco nuevo para la segunda versión de este programa, struct1.c
también.
También es un adelanto sobre la dirección
que llevaremos en esta serie de problemas, en la próxima,
y en todas en las que usemos archivos, dado que los archivos en una computadora
solo son un montón de bits, ceros y unos.
Esos ceros y unos siguen algún patrón.
Pero aún tenemos que ver un mecanismo para guardar los archivos.
Pero así es como podemos hacerlo.
Encima de esta línea, la 21 y más arriba, el mismo programa.
Tenemos un montón de students del usuario, por su name y dorm.
Después, en la línea 24 vemos algo que es
nuevo, por ahora solo vimos esto en el problema forense.
Llamamos a una función llamada fopen, que significa archivo abierto.
Eso toma dos argumentos, según su documentación.
El nombre del archivo que deseamos abrir y luego el segundo argumento
es cómo queremos abrir el archivo.
E incluso si nunca antes hemos visto esto,
¿Qué podría representar la w?
PÚBLICO: [INAUDIBLE]
DAVID: Sí, es cierto.
Es read y write, dos de las operaciones más comunes para una computadora.
La r sería read y la w para write.
Esto tiene sentido, si el objetivo es guardar estos students en un archivo
para que el programa sea útil si lo ejecutamos una y otra vez.
A la izquierda tenemos un nuevo tipo de datos.
Todos en mayúsculas, lo cual es un poco anormal, incluso en C.
Pero FILE *file dice, oye, dame una variable en formato file
que pueda almacenar la ubicación de un archivo, por así decirlo.
Y técnicamente, esa no es la ubicación del archivo en el disco.
Es la ubicación en la RAM, una vez que hayamos abierto el archivo.
Pero por ahora, pensemos que esto es una abstracción para el file.
Y simplemente se llama file.
¿cuál es el propósito en la vida, de la línea 25?
Aunque nunca antes hemos escrito este código.
Sí, ¿qué piensan?
PÚBLICO: Si el archivo existe.
DAVID: Si el archivo existe, o más general, si el archivo se abrió
correctamente.
Porque podrían salir mal muchísimas cosas.
Uno, como lo suponen, el archivo podría no existir.
Dos, tal vez no tenemos permiso, entonces existe pero no podemos abrirlo.
Tres, tal vez no haya suficiente memoria en la computadora
para abrir un archivo más.
Cualquier cantidad de cosas podría salir mal.
Recordemos, ¿cuál es el valor centinela especial que representa los errores
ahora que estamos en el mundo de los punteros?
Null o N-U-L-L todo en mayúsculas es lo contrario a tener una ubicación válida.
Significa que no hay tal ubicación.
Y lo usamos para comprobar si hay errores.
Eso es todo lo que hace la línea 25.
Todo lo demás es casi idéntico al programa anterior,
excepto por que no usamos printf. ¿Qué es lo que al parecer usamos?
fprintf y adivinen ¿qué significa la f en printf?.
Sí, es file printf y funciona casi igual
excepto que necesitamos un argumento más.
El primer argumento no es una cadena de formato
como lo ha sido durante años.
Es una variable que hace referencia a un archivo abierto y luego el resto de este
todo es lo mismo.
Lo que es genial sobre fprintf es que no sólo
imprimimos en la pantalla,
sino en cualquier archivo que tengamos
abierto para que después, en la última línea
aquí, cuando llamo fclose, eso sea el equivalente a guardar el archivo.
Luego el programa simplemente se cierra.
Al final, lo que es genial sobre esto es,
que si sigo adelante y me desplazo hacia arriba,
entraré en mi directorio fuente 5,
continuaré en make struct1.
structs.h no encontrado.
¿Qué hice mal?
Bueno, acabo de arruinarlo antes del curso y nombré mal este archivo
¡eso no sucedió!
Ya compilé el programa.
Muy bien, para continuar ejecutaré esto, ./struct1 enrollment será
3.
Diremos que será María quien esté en Cabot House y Brian quien
está en Winthrop.
Y digamos que David estaba, digamos, en Mather.
Enter y nada sucede.
Pero si ahora escribo ls y miro dentro de este directorio,
noten que tengo un archivo llamado a propósito students.csv
porque si alguna vez usaron Excel o Numbers,
son muy comunes los archivos csv, un formato de valores separados
por comas.
Es una base de datos muy simple.
Si abro esto verán que, de hecho, los contenidos de este archivo
están separados por comas.
Y si tuviera que abrir este archivo en Excel, cada una de estas columnas
se abrirían y visualmente serían exactamente como eso.
Entonces, lo que hice con mi printf, si vuelvo a structs1.c,
Noten como conscientemente incluí esa coma allí,
para crear este tipo de formato de base de datos falso.
Y solo por si acaso, déjenme ver si
podemos descargarlo desde el administrador de archivos del IDE,
luego abro students.csv.
Si el programa coopera aquí tenemos Microsoft Excel.
Me hice una pequeña hoja de cálculo,
usé un código en C.
Rápidamente encontraremos que esto no es
tan útil para hacer archivos csv.
Debido a que entre más filas agreguemos a estos archivos, más y más lento
será el buscarlos.
La próxima semana haremos la transición hacia la programación web
y reemplazaremos las hojas de cálculo o los formatos csv, como este,
por algo más poderoso, es decir, las bases de datos.
Por lo que esto, es un adelanto de lo que está por venir.
Pero, ¿dónde comenzamos esta conversación?
Al parecer, todo nos conduce a lo que hay
dentro de nuestra computadora, en lo cual continuaremos abstrayéndonos.
No es necesario que comprendamos cómo funciona este hardware.
Ya antes dijimos, que podíamos pensar
en dividir la memoria de nuestra computadora en una cuadrícula,
a fin de numerar todos los bytes.
De modo que tengamos ubicaciones específicas, también
conocidas como direcciones o punteros.
La última vez aclaramos, que no toda la memoria se
se ocupa de la misma forma dentro de la computadora.
Por el contrario, cada fragmento de esta se utiliza de forma diferente.
En la parte superior, digamos, aunque en realidad no hay tal parte superior,
es solo es una interpretación del artista.
Así que, la parte superior de la memoria de nuestra computadora podría
ser el heap, donde almacenamos ciertos tipos de valores
y aquí abajo está lo que llamamos stack, donde
almacenamos otros tipos de valores.
Y si hacemos un alejamiento, hay diferentes capas de memoria.
Separemos lo qué está sucediendo aquí,
Si cuando ejecutamos un programa, tenemos acceso
a uno dos gigabytes de RAM y de hecho, eso es
lo que hace tu Mac o PC.
No importa la cantidad de RAM que tengamos, la computadora
por lo general, nos da la ilusión de que tenemos acceso a toda.
Esto serían dos gigabytes de memoria.
Bueno, una de las primeras cosas que pasa,
es que los ceros y unos que conforman nuestro programa, ya sea
que se llamen A.out, Caesar, Vigenere o Structs1, esos ceros y unos
se cargan aquí arriba, en la memoria de nuestra computadora.
Así, el segmento de texto en la memoria es un nombre raro,
para los ceros y unos de nuestro programa.
No es texto ASCII.
Literalmente son los ceros y unos del programa que compilamos.
Luego están los que llamamos datos de inicio o no inicializados.
Lo que básicamente significa, que cualquiera de las variables globales
que tengamos en el programa, se almacenarán aquí o aquí.
Si les asignamos valores en la parte superior del programa,
estas se iniciarán por definición,
pero si no lo hicimos, no se iniciarán.
En este caso, el compilador establece las cosas un poco diferente.
En el extremo inferior hay algo llamado variables de entorno,
las cuales no ocupamos demasiado, pero las utilizaremos
en algunas semanas para la programación web.
A menudo, almacenaremos nombres de usuario, contraseñas y otros valores
que no queremos guardar en el código,
pero queremos que nuestra Mac, PC o el servidor tengan acceso de algún modo,
por ello nos enfocaremos más en estos, stack y heap.
Ya vimos un par de ejemplos de cada uno, pero brevemente.
¿Cómo usamos stack o en qué caso dijimos que lo usaríamos, la última vez?
PÚBLICO: [INAUDIBLE]
DAVID: ¿Dilo de nuevo?
PÚBLICO: [INAUDIBLE]
DAVID: con int main void y de manera más general,
con las funciones cuando las llamamos.
Main es, por supuesto, el que irá en todos los inicios de los programas,
pero podría llamar a otra función como swap
y swap, por sí mismo, podría llamar a algo más, tal vez a printf.
Cada vez que llamamos una función, esta ocupa una porción o un marco de la memoria.
Y aumenta cada vez más, conforme se llama a esas funciones.
Al menos teóricamente, al final esto fue esclarecedor,
en cuanto a por qué este programa no sirvió.
El programa que vimos la última vez, en los programas de swap y noswap,
afirmamos que esta implementación fue incorrecta.
Pero aún creo que cuando Kate apareció, e hicimos el ejemplo con el intercambio
de los sabores de Gatorade, esto fue más o menos
una interpretación de eso en el código.
Es correcto en un sentido e incorrecto en otro.
¿En qué sentido fue correcto este código?
En el programa noswap,
porque lo revisamos brevemente con el depurador.
PÚBLICO: [INAUDIBLE]
DAVID: OK, necesitábamos una variable o contenedor temporal,
para almacenar uno de los valores o de los sabores de Gatorade.
Y para cuando llegamos a esta tercera y última línea
en esta función, ¿qué podríamos decir sobre a y b?
PÚBLICO: [INAUDIBLE]
DAVID: Sí, de hecho fueron intercambiados.
Vi eso, creo que al conectarlos, creo que tuve problemas con el depurador
de modo que usé eprintf en el último minuto,
para ver lo que sucedió después de esa tercera línea.
Entonces funciona.
Esta función intercambia a y b, ¿pero qué es lo que no intercambia?
PÚBLICO: [INAUDIBLE]
DAVID: A x y y, las cuales fueron las variables en main.
Recordemos la historia que contamos la última vez,
dijimos que si solo nos enfocamos en el stack de la computadora,
esa porción inferior de la memoria y cuando se llama a main
se ocupa un fragmento de la memoria, esta parte de abajo,
porque es la primera función en llamarse.
Recordemos que tenía a las variables x y y, cuyos valores eran 1 y 2.
Cuando main llamó a swap, la otra función que acabamos de ver,
tenía a los valores a, b y también a tmp que al inicio eran 1 y 2
y finalmente se convirtieron en 2 y 1.
Pero esta imagen responde a toda la pregunta,
la razón por la que x y y no cambiaron es porque
en esa versión roja solo cambiamos a y b.
Así resolvimos el problema, recuerdan ¿cuál era la función nueva en C?
PÚBLICO: [INAUDIBLE]
DAVID: Los punteros y direcciones.
En vez de devolver la función, los valores que deseamos intercambiar,
démosle a la función un mapa del camino, por así decirlo, a esos valores
para que la función pueda ir a los valores que nos importan,
y moverlos dondequiera que estén.
Al principio la sintaxis se ve algo rara,
parece como si multiplicáramos por todos lados,
pero tiene dos usos diferentes.
Si tenemos el asterisco aquí arriba y un tipo de datos como int junto a él,
esto dice, oye computadora,
dame una variable llamada a, pero eso no almacenará un int.
¿Qué almacenará?
PÚBLICO: [INAUDIBLE]
DAVID: Sí, la ubicación de un int en la memoria, al igual que b
almacenará la ubicación de otro entero.
Son una especie de marcadores de posición para las direcciones.
Más adelante está el Centro de Ciencias.
Si la dirección del Centro de Ciencias es 1 Oxford Street, Cambridge,
Mass 02138, esa dirección postal es la única que identifica
a ese edificio.
De manera similar, en una computadora los valores tienen ubicaciones únicas.
Pero son mucho más simples.
Son números, no tienen calles, ni códigos postales, ni nada de eso.
Pero es exactamente la misma idea.
Pero aún tenemos una variable tmp de tipo int: int tmp = *a;
Así que denme una variable temporal como la que tenía Kate,
el vaso extra que al inicio estaba vacío.
*a, sin embargo, sin un tipo de datos a la izquierda, ¿qué quiere decir?
En este contexto *a, es una oración en inglés, diferente a esta
otra,
que significa, denme un puntero para un int o declaren una variable para mí,
que almacene la ubicación de un int.
Pero esto dice, ve a esa ubicación.
Esto es, que a esta en la ubicación y *a significa ve a esa ubicación, para que
lleguemos al valor, el cual en la historia, probablemente es uno.
*a significa lo mismo.
De nuevo, ve a esa ubicación
y después, por cierto, ve a la ubicación de b.
Cualquiera que sea ese valor, colócalo donde apunta este dedo.
Luego *b significa ve a esa dirección y pon lo que haya en el vaso temporal
de Gatorade o en este caso, el valor uno.
Así, los punteros, aunque tienen una manera muy complicada
de arreglarlo, básicamente solucionan el problema, porque
en vez de pasar el 1 y el 2, en su lugar pasamos aquí la ubicación de x
y la ubicación de Y que la computadora permitió
para luego ir a esas ubicaciones en la memoria y
hacer lo que queríamos que hiciera.
Para resumir la historia, así es como se usa el stack de la computadora.
Cuando llamamos a una función, esta ocupa una nueva porción de la memoria, arriba
de cualquier otra función que llamemos.
Tan pronto como esa función termine de ejecutarse,
está memoria se borra de manera eficaz.
Técnicamente sigue ahí, porque es el hardware,
no desaparecerá físicamente.
Pero cualquiera que sea la próxima función que main llame, tal vez printf o algo
más, reutilizará esa memoria de todas las maneras
que necesite para sus propios valores y parámetros locales.
Dada esa definición, el hecho de que el stack, al igual
que las bandejas de la cafetería crezcan hacía arriba y hacía abajo
y arriba y abajo, es cómo el programa llama a las funciones y esas funciones
regresan, ¿de dónde salen los valores basura?
PÚBLICO: [INAUDIBLE]
DAVID: Las cosas viejas que estaban en esa memoria.
Tiempo atrás hice esta afirmación radical
de que no debemos confiar en las variables, si no pusimos
nosotros mismos los valores en ellas, porque tienen los llamados valores basura.
Pero eso tiene un significado muy preciso.
Eso significa que si nosotros llamamos a una función y esta
necesita una variable que está aquí y esto es como un minuto
en la ejecución de nuestro programa, hay un montón de funciones diferentes
que esa parte de la memoria podría haber usado y no usado, usado y no usado.
Allí solo tendremos ceros y unos en algún patrón persistente.
La computadora podría estar muy a la defensiva y cambiar
todos los bits a cero, todo el tiempo.
Esa podría ser una decisión de diseño razonable,
pero resumen, C no hace eso.
Eso llevaría mucho tiempo, en especial hace años
cuando las computadoras eran más lentas,
y el lenguaje no estaba bien desarrollado,
y simplemente no era convincente hacer esto.
Por lo que obtenemos los valores basura, los cuales casi siempre tengo
escritos como signos de interrogación.
Pero esa es la razón.
Hay valores basura allí porque, son nuestros propios valores anteriores,
o los de algunas funciones.
Por otra parte, el heap era diferente.
El heap es el extremo superior de la memoria
que en cierto sentido conceptual, está encima del stack.
Está aquí arriba.
Es diferente en el sentido de que funciona más como almacenamiento a largo plazo.
El stack es para el almacenamiento a corto plazo y
se usa localmente cuando se ejecuta una función.
Pero supongamos que nuestro programa se ejecuta por mucho tiempo,
o queremos una función para asignar la memoria que no se usa
y que no se convierta de inmediato en valores basura.
En efecto, piensen en get_string.
get_string es una función que escribimos y su propósito en la vida
es obtener una cadena del usuario, la cual es un montón de caracteres.
Y consideren esto.
Si se llama a get_string y se ocupa una porción de memoria en el stack
y yo escribo el nombre María, M-A-R-I-A y eso se vuelve un secret \0.
¿Dónde se almacenaron M-A-R-I-A y \0?
¿Esto puede, por definición, almacenarse en el stack?
¿Por qué?
PÚBLICO: [INAUDIBLE]
DAVID: Exactamente.
Si asignamos el espacio en el stack, lo cual podríamos hacer con un arreglo,
y luego devuelve la ubicación de esa cadena,
lo que sería válido en cierta forma, porque nosotros
asignaríamos la memoria.
Para continuar, lo dibujaré así.
Si tuviéramos una función get_string y get_string es llamada
por main, eso significaría que get_string tiene este fragmento de memoria
aquí.
Y si María o yo continuamos y escribimos su nombre,
eso asignaría espacio para M-A-R-I-A \0
y podemos pensar que esto como un montón de bytes en ese marco.
Entonces existen y están almacenados en la memoria.
Así podría devolver la ubicación de este primer byte, sea cual sea.
Tal vez esto sea un byte 10, 100 o los que sean.
Y podría devolverle esa ubicación a main, como un valor de retorno de get_string.
Pero en cuanto lo haga, sí, exactamente,
técnicamente la memoria no va a ninguna parte.
Pero ya no es confiable.
Ahora todo eso es un valor basura.
Pero podríamos tener suerte
y si intentamos imprimir el valor de retorno de get_string, veremos a María,
pero brevemente, porque la próxima vez que se llame una línea de código
o se utilice de nuevo la memoria, el nombre de María
podría sobrescribirse con algunos otros valores,
porque el nombre se convierte, por definición, en un valor basura.
Y no sabemos cuándo se volverá a usar.
Eso no es seguro
y esta es la razón de que heap exista.
Si necesitamos mantener algo en la memoria por un tiempo, como se supone get_string
lo hace, podemos asignarlo a cualquier otro lugar que no desaparezca
hasta que nosotros mismos lo liberemos.
La última vez presentamos un par de funciones nuevas,
malloc para la asignación de memoria y
existe un opuesto de ella, free, que
necesitaremos en las series de problemas relacionados con la gestión de la memoria
y para deshacer la asignación aquí.
De lo contrario, terminaremos con lo que se llama una pérdida de memoria,
la computadora se volvería lenta y se agotaría la memoria,
porque no lo devolvemos,
como nota aparte, hay un par de primos
de malloc, calloc y realloc.
calloc es genial en cuanto a que la c significa claro.
calloc es idéntico a malloc, pero pone todo el fragmento de memoria en cero
para nosotros.
De modo que, si queremos inicializar para no tener ningún valor basura,
usaremos calloc en lugar de hacerlo con un bucle for o algo
como eso.
Realloc, veamos, es una función más poderosa
nos permite tomar un fragmento de la memoria y expandirlo.
En un momento veremos lo que eso significa.
Pero con este poder viene una gran responsabilidad,
vimos que las cosas salen terriblemente mal para Binky
cuando se usan mal las direcciones de la memoria.
Recordemos que vimos brevemente este programa gracias al video de Nick
de Stanford.
Veamos qué representan estas líneas del código.
Aquí y aquí declaro dos variables, x y y
En términos generales ¿Qué es lo que almacenarán?
Ubicaciones de enteros.
Eso es todo lo que sucede allí.
Esta es una línea del código que vimos la última vez, x = malloc (sizeof(int));
la cual dice, llama a malloc, para que asigne cierta cantidad de memoria.
¿Cuánta memoria queremos?
Cualquiera que sea el tamaño de int.
Las probabilidades son cuatro, tal vez ocho, tal vez un poco de ambos
o algún múltiplo de dos o de cuatro.
¿qué devolvemos aquí?
Un fragmento de memoria o específicamente, su ubicación y nosotros
lo almacenamos en x. Esta línea: *x = 42 dice, ve a esa ubicación
y coloca allí el número especial 42.
La próxima línea *y = 13, sin dudarlo dice, ve a la ubicación en y
y coloca allí el desafortunado número 13.
Pero ahí es donde Binky tuvo un accidente porque
¿qué estaba dentro de y en ese momento?
PÚBLICO: [INAUDIBLE]
DAVID: La memoria no le permitió tocarlo.
¿Por qué?
Sean más precisos.
PÚBLICO: [INAUDIBLE]
DAVID: Están muy cerca.
En última instancia eso explicará el síntoma ¿pero?
PÚBLICO: [INAUDIBLE]
DAVID: Exactamente.
No pedí más memoria.
Así que por defecto, aunque parezca extraño,
int *y significa dame espacio para una ubicación.
Significa dame cuatro u ocho bytes de memoria RAM.
Pero ¿por definición, qué hay dentro de una variable?
Únicamente valores basura.
Habrá un número allí y estos son nuestras ubicaciones.
Por lo que parecerá que allí hay una ubicación.
Técnicamente, es correcto decir, ve allí,
pero es como seguir un mapa en el que no tenemos idea hacia dónde vamos.
Podríamos recorrer la orilla de algún lugar
y ahí es cuando las cosas salen mal.
Esta era la visualización que Nick realizó con plastimación.
Si tenemos esto y parece que no
importa si el asterisco está a la izquierda o a la derecha, justo aquí.
Pero por convención lo pondremos en el lado derecho,
junto a la variable.
El int *x e int *y, es como si dijéramos,
dame un fragmento de memoria o de arcilla para cada variable.
Y él encerró la pequeña punta de flecha en la cadena
porque había memoria para eso.
No apunta hacia ningún lado en específico.
En este momento es un valor basura.
Este fue el siguiente capítulo de la historia.
Asignarle un espacio al int, aquí dibujado en arcilla blanca.
Y por la asignación, Nick dice que x, la cual de nuevo es un puntero,
señalará ese fragmento de la memoria.
De modo que ya no es un valor basura
y señala hacia algún lugar en específico.
Por eso Nick fue capaz de decir, ve allí,
sigue la flecha y coloca allí el número 42,
pero la siguiente línea del código salió terriblemente mal,
porque no apuntaba hacia ningún lado.
Nick intentó decir ve allí y coloca el 13, pero allí no hay nada, de modo que
la computadora se bloquea.
Una falla en la segmentación, significa que tocamos un segmento de la memoria
que no deberíamos tener, porque tratamos de ir a algún lado
donde solo hay valores basura.
Recordemos que para arreglarlo, la solución sería esto,
si en vez de eso retrocedemos para arreglar a Binky
y decimos que y = x; eso no asigna espacio extra.
Solo nos dice que el punto y esta en el mismo fragmento de memoria que x,
porque nuevamente, x y y solo son direcciones.
Si la dirección en la memoria es 100, x será 100 y y será 100.
Ambos apuntan al mismo fragmento de arcilla blanca.
Cuando él utiliza a *y obtiene 13 y eso indica, ve allí
y actualiza el número.
Por eso el 42 se convirtió en el 13.
Es algo muy similar a nuestro ejemplo de capitalización,
cuando señalamos las cadenas en el mismo fragmento de la memoria.
¿hay alguna pregunta sobre el stack o sobre lo que vimos en Binky, el heap?
malloc asigna la memoria del heap,
pero cada vez que declaramos variables locales o arreglos
dentro de una función, eso termina en el stack.
Hasta ahora malloc, calloc o realloc son las únicas herramientas
que nos dan acceso a la porción de memoria que representamos como arcilla blanca
aquí y como lo vimos en la parte de arriba de nuestro diagrama.
¿Alguna pregunta?
Sí.
PÚBLICO: [INAUDIBLE]
DAVID: Buena pregunta.
Tengamos en cuenta que x y y son los mismos tipos de datos.
Ambos son punteros para ubicaciones.
Por eso, si establecemos a uno, debe ser igual al otro
y tenemos que almacenar el valor que está en uno, dentro del otro.
De lo contrario, sería como poner un número entero
dentro de un puntero, lo cual no es del todo correcto a pesar de que
técnicamente ambos son números.
Esta y = x; dice que cualquiera sea la ubicación de x,
pongamos la misma ubicación en y, no dice nada acerca de ir a esa ubicación.
Es similar a fotocopiar un mapa, pero sin seguir
ese mapa aún.
Hasta esta línea, la cual dice, sigue la copia del mapa
y nos conduce a la misma ubicación.
¿Sí?
PÚBLICO: [INAUDIBLE]
DAVID: Si regresamos, en C solo podemos regresar un valor.
Así que estaremos en un aprieto porque en teoría, queremos regresar dos valores,
¿cierto?
Tanto a como b, queremos volverlos b y a. Existen
maneras en las que podemos trabajar para que regresen dos valores
y verlos.
Pero en resumen, es mucho más fácil decirlo que hacerlo.
En Python sería mucho más fácil,
Pero lo malo de C es que no podemos regresar varios valores.
Y en este caso eso nos ata de manos.
OK probablemente esto tenga relación con otras cosas, han
escuchado sobre este sitio web, que es sumamente útil cuando
tratamos de aprender un lenguaje nuevo, o en el mundo real
tratamos de resolver algún problema, porque esta es
una comunidad con personas maravillosas, que publican preguntas y respuestas,
y se enlazan mediante referencias convencionales para que entendamos
el porqué de alguna respuesta.
Pero esto tiene un significado muy técnico, stack overflow.
Y stack, por supuesto, ahora es un concepto que conocemos.
Y podemos imaginar que algo como esta imagen, en definitiva
saldrá muy mal si llamamos a tantas funciones, que nos quedemos
sin memoria,
o que ni siquiera se ejecuten como tal.
¿A la larga, qué podríamos afectar si seguimos llamando a una función,
después una función, después de una función?
El heap.
Entonces ocurrirán cosas malas.
Si nuestros marcos del stack y sus variables locales
son tan numerosos que comenzamos a sobrescribir la memoria del heap
los valores que asignamos con malloc se destruirían.
Esta no es la mejor decisión sobre el diseño, pero es la realidad.
Esto solo se reduce mediante el uso de un compilador que
nos permita notar esto, o si no utilizamos
más memoria de la que necesitamos.
Ahora, el stack overflow, tiene un significado muy técnico aquí,
al igual que el heap overflow,
stack overflow significa que sobrecargamos el stack,
llamamos tantas malditas funciones, que tocamos la memoria
que no debíamos.
heap overflow sería todo lo contrario,
es cuando seguimos llamando a malloc y malloc y malloc
y el heap sobrecarga al stack.
Porque para bien o para mal, el stack aumenta de esta manera
y el heap aumenta de esta manera.
Hasta que con el tiempo se golpearán entre sí.
Esta es la manera más sencilla de decir desbordamiento de búfer.
El búfer es un fragmento de la memoria que almacena datos o valores
y nosotros, las personas en el mundo real seguro escuchamos
de esto en algún vídeo, en YouTube o en diversos reproductores de vídeos.
Si alguna vez vimos la expresión buffering ta-ta-ta,
es la cosa más exasperante,
cuando algo está por suceder en la película o espectáculo que estamos viendo
y la maldita cosa empieza a cargar y cargar y cargar.
¿Qué significa esto?
Bueno, el reproductor de vídeo, YouTube u otra cosa,
tiene un fragmento de la memoria, el cual podemos pensar como un arreglo
y cargados en ese arreglo están los ceros y los unos
que conforman la película o el programa de TV que estamos viendo,
y que a su vez se descargaron del Internet.
Con suerte, la velocidad del Internet será más rápida
que la reproducción de la película
y aunque estemos en el minuto 10 del video,
es de esperar que la computadora descargará del minuto 10 al 11
para que acumulemos los bytes en el búfer
durante un minuto entero y así podamos mirarlo
incluso si nuestra conexión a Internet falla.
Cuando el video se almacenó en el búfer, tenemos este arreglo de memoria
y hemos visto o mirado todos los bytes en él
y el búfer ahora está vacío,
pero también puede ocurrir lo contrario.
si intentamos descargar más bytes de los que caben en la memoria,
tal vez podríamos poner los minutos del video en algún lugar
que no pertenezca a nuestra computadora.
O si llamamos demasiadas funciones, o a malloc demasiadas veces,
es probable que sobrecarguemos el fragmento de memoria asignada.
Así que los búferes están por todos lados
y de hecho, una cadena tal como la conocemos es un búfer,
es un arreglo de memoria y con suerte
solo pondrá tantos caracteres en esa cadena,
como puedan alojarse en ese fragmento de memoria.
¿Qué cosas pueden salir mal?
Este es un pequeño ejemplo inventado, pero
viene con un par de gráficos para pintar
una imagen, de cómo los enemigos hackean los sistemas
que están escritos en lenguajes como C. Aquí tenemos un programa rápido,
vamos a #include
abajo tenemos a int main, que toma argumentos de la línea de comandos.
Observen que esta función: foo(argv[1]); no hace verificación de errores.
Es bastante tonta,
solo llama una función foo y pasa a un argv[1].
La idea aquí es que este es un programa en el que
si tomamos un argumento de la línea de comandos, una palabra después del nombre
del programa, nos llevará directamente al programa foo.
OK.
A continuación, ¿qué hace foo?
Acepta como entrada una cadena void foo (char *bar) y la llamaremos bar,
luego asigna un arreglo en el stack llamado c de tamaño 12: char c[12];
E incluso si nunca antes hemos visto esta función,
tal vez podamos deducir de su nombre memcpy, algo como copia de la memoria.
Entonces, esto se va a copiar en esta memoria, sea lo que sea,
en esta memoria, hasta esta cantidad de bytes.
Si escribo María como el argumento de la línea de comando, M-A-R-I-A tiene cinco,
eso significa que la longitud que escribí, será cinco
y copiará cinco bytes de bar hacia C, eso es todo.
Pero está destinado a ser solamente un monstruo,
a fin de cuentas este programa es bastante inútil.
Pero es como destilar un proceso en las pocas líneas del código.
¿Cómo se ve esto o qué está sucediendo?
Llamamos a la función: foo(argv[1]);
Asignamos 12 bytes: char c[12];
Copiamos 5 bytes en esos 12 bytes: memcpy (c, bar, strlen(bar));
Parece que todo está bien en la historia.
Pero, ¿qué sucedió en la memoria?
Aquí hay una foto amplificada y muy bien coloreada del stack.
Así que el stack va de esta manera.
El heap aumenta de esta manera.
Esto técnicamente nos muestra cómo se distribuyen las cosas en el stack.
Simplifico el mundo al dibujar cosas
como x, y, a y b, pero siguen un cierto orden.
Y específicamente, si tenemos una variable local
llamada bar, la cual hicimos para esta función,
esta irá aquí abajo.
Si luego declaramos un arreglo llamado c, irá directamente aquí arriba.
Y estos son de tamaño proporcional,
este tiene cuatro bytes
y este será de 12 bytes,
todo tiene un tamaño proporcional.
Luego, no entraremos en muchos detalles
pero si te gustan estas cosas, CS 61 y otros cursos lo explorarán,
pero otra cosa que siempre estuvo guardada en el stack,
en secreto, es lo que se llama una dirección de retorno.
Cuando main llama a swap, es como entregarle las llaves del coche
a otra persona.
Es algo como swap, ve a hacer lo tuyo.
Pero main tiene que decirle a swap o a cualquier función que llame,
cómo devolver su fragmento de memoria, para que la ejecución se reanude con main
tan pronto como swap termine de ejecutarse.
Y no es su propia memoria de pila.
¿Recuerdan la parte superior de la memoria que describí como el segmento de texto?
¿Todos los ceros y unos que conforman nuestro programa?
Cuando main llama a swap o a alguna otra función,
introduce su propia dirección de retorno, la ubicación correcta de los ceros
y los unos en ese segmento de texto, en cuatro o tal vez ocho bytes,
la ubicación a la cual swap debe devolvernos las llaves, digamos.
De otro modo, es como si main le entregara las llaves a otra función
y luego nunca vuelve a saber de ella y las otras líneas del código de main,
nunca se ejecutan.
En resumen, debe existir una ubicación o un mapa escondido
en el stack, para que swap pueda devolverle el control a main.
Pero, ¿qué sucede aquí cuando usamos esta memoria?
Bueno, si numeramos los bytes que hay en el stack y
eran de tamaño 12, el primero es 0 y el último es 11,
por lo que del 0 al 11 nos da un total de 12.
Si escribimos algo como María o de manera más general, hola, H-E-L-L-O,
que tiene la misma longitud, técnicamente eso utiliza 6 bytes,
por el \0 y todo está bien.
Se ajusta cómodamente en c y tenemos espacio para respirar.
Pero, ¿y si no escribimos MARIA o HELLO?
¿Qué pasa si escribimos una oración muy larga de más de 12 caracteres?
¿Dónde van a terminar?
Si escribimos una cadena más larga en la línea de comandos, en argv[1],
noten que el código tiene errores.
Verifiquemos la longitud de la palabra que el usuario ingresó,
copiaremos todos los bytes de bar en c. Pero ¿qué pasa
si la longitud de la cadena que escribimos es de 13?
¿Qué vamos a hacer?
Copiaremos los 13 bytes de bar en c,
y así llenaremos todos los 12 bytes, más uno
extra que no deberíamos tocar.
Y si la cadena es incluso más larga que 13,
si el enemigo escribió una oración larga, una frase, una palabra o lo que sea,
sobrepasaremos los límites,
de ese búfer o de ese arreglo.
Si escribimos una palabra mucho más larga, ¿Qué aspecto tiene esto?
como A-A-A-A-A-A-A, terminaríamos de sobrescribir estos 12 bytes,
también estos cuatro bytes, o esos bytes verdes, sea cual fuesen,
y lo más importante, los bytes rojos que describí como dirección de retorno.
Ahora, A-A-A-A-A no causará ningún problema
porque es una secuencia de caracteres aleatorios ASCII.
Pero los caracteres al final del día son solo números,
los números son solo bits, y los programas son solo bits.
El peligro aquí, es que si eres un enemigo bastante sofisticado, alguien
que conoce de programación, técnicamente
escribirías un programa que hace algo extremadamente malo, como eliminar
todos los archivos en un disco duro.
O enviar spam a todos tus contactos.
O algo por el estilo porque al final del día,
el programa que acaba de escribir, son ceros y unos.
Si convertimos ese programa de ceros y unos al correspondiente,
incluso si los valores ASCII extraños, que podríamos escribir en un programa
en la línea de comando en argv[1] al escribir los caracteres raros
en las claves que no tendrán sentido para un humano que las lea.
Pero esos caracteres ASCII en el contexto de un programa
serán interpretados como un código.
Si eres realmente bueno,
no tanto que seas muy bueno,
Si por mucho ensayo y error, sucede que
al sobrescribir la dirección de retorno en un programa,
puedes engañar a la computadora para que no regrese
a main, pero que salte a la misma entrada que pasamos dentro del programa.
A implica un ataque, como un código de ataque.
Si somos inteligentes pasaríamos a un patrón apropiado de ceros
y unos, que se convierten en ASCII para que el humano los escriba en el cursor,
sobrescriba esta dirección de retorno y engañe al programa de computadora
para que regrese a esta función no a main, parecida, a este byte de aquí.
Y tal vez este byte, junto con todos estos otros,
signifiquen, eliminar todos los archivos en el disco duro de este usuario,
enviar spam a todos sus contactos.
Esto es llamado explosión de desbordamiento de buffer,
es increíblemente fatal y común, incluso en estos días.
C no se usa comúnmente para muchos programas, pero sigue en todas partes.
También hay otros lenguajes, como C ++, que se prestan a esto.
Aunque esto todavía es un poco misterioso
y no necesitamos preocuparnos demasiado por las direcciones en la pantalla,
la amenaza fundamental aquí es que si no
verificamos los límites de nuestros arreglos, cantidad de memoria asignada,
y tocamos la memoria que no deberíamos, sucederían cosas muy malas.
De hecho, le damos el control a cualquiera
en Internet que usaría nuestro programa porque sería lo bastante inteligente
para inyectar sus propios ceros y unos, en nuestro programa para su ejecución.
Ok.
Dios mío, esto es aterrador en las ciencias de la computación.
¿Qué haríamos para defendernos de esto más allá de no escribir errores,
que nunca sucederían, cierto?
Incluso los mejores y más avanzados programadores aún
cometen errores, porque el software se vuelve cada vez más y más complicado.
Tenemos eprintf, tenemos help50 y tenemos debug50
pero hay muchas otras herramientas, como Valgrind
que es una herramienta para detectar fugas de memoria en un programa
y otros problemas relacionados con la memoria.
Abriré este programa, memory.c,
que se ve así.
Veamos si no podemos desentrañar lo que falla en este programa.
Aquí está el programa.
Incluye lib.h estándar, función f, función main, la función main llama a f
y devuelve 0.
No hay nada interesante aquí.
¿Qué hay en f?
F en el lado derecho asigna espacio para 10 enteros, creo.
Malloc devuelve la ubicación de ese fragmento en la memoria y la almacena en x,
y la línea 8 es el error, creo.
¿Qué está mal en la línea 8?
Aquí primero.
PÚBLICO: [INAUDIBLE]
DAVID: Exacto, porque comenzamos a contar desde cero
y porque le pedimos a la computadora espacio para 10 ints,
que nos devuelve.
Pero eso será accesible a través de [0], o como dicen, [9].
El [10] es como escribir un int más allá del búfer, conocido como
una sobrecarga de búfer.
Puede ser difícil ver esto, en especial cuando el programa no es
tan corto como este, que está enterrado bajo una docena de líneas, cien
líneas, miles de líneas de código.
Las herramientas pueden ayudar.
Proseguiré a hacer este programa con make memory.
Continúo y ejecuto: valgrind ./memory Enter,
el único inconveniente de este programa es que su resultado es completamente
abrumador.
Si no separamos algunos términos reconocibles,
todo lo que se encuentra a la izquierda, es un poco una distracción.
Esta es información de derechos de autor.
Parece que las cosas interesantes suceden aquí.
Veo el ajuste de tamaño 4, inválido.
No estoy seguro de lo que significa todo esto, pero
por alguna razón se relaciona con, oh, oh, línea 8 de memory.c, línea
13, línea ocho, línea 13, parece que hay muchos errores en las mismos sitios.
La ubicación tal y tal, cero bytes después de un bloque de sys
40 alloc, es un poco difícil entender esto.
Como siempre, al menos inicialmente, ejecutemos esto a través de help50
y ver si ayudaría a desentrañar esto.
Vemos el mismo resultado
que reconoce algo familiar en amarillo, aquí.
El ajuste del tamaño cuatro es inválido y resalta las líneas.
Nuestra retroalimentación tipo TF es, parece que
trata de modificar 4 bytes de memoria que no es nuestro, signo de interrogación?
¿intentamos almacenar algo más allá de los límites de un arreglo?
Demos un vistazo más de cerca a la línea 8 de memory.c.
Es una especie de trabalenguas, pero es
porque practicamos leyendo cosas bastante misteriosas como esta.
Extrajimos todos los detalles destacados, como la línea ocho de memory.c.
la línea ocho de memory.c, como notamos, es la línea peligrosa.
¿Qué significaría tener un ajuste de tamaño cuatro inválido?
Un int en el IEDE, ¿a cuántos bytes equivale?
Cuatro, o 32 bits.
El ajuste de tamaño 4 inválido significa que este int cero de aquí, es un int,
es de cuatro bytes, se escribió de manera inválida, como decimos,
en la ubicación incorrecta.
Esta es la manera bastante concisa de Valgrind de comunicar esa idea.
Aquí tenemos una explicación.
¿Cómo puedo arreglar esto?
Si mi intención era actualizar la última área allí,
a continuación haré: make memory, Enter, ./valgrind ./memory Enter.
Y ahora esto es algo bueno, excepto que hicimos algunos progresos.
Me desplazaré hacia arriba para ajustarlo más en la pantalla.
Me deshice de ese mensaje, ajuste de tamaño cuatro inválido,
esto tampoco suena bien.
se perdieron 40 bytes de un bloque en el registro perdido uno de uno,
¿correcto?
Necesito un poco de ayuda con eso.
Permítanme aplicar help50 de nuevo hasta que me familiarice con la sintaxis.
Se destacó ese fragmento de salida.
Parece que nuestro programa filtró 40 bytes de memoria.
¿Olvidamos liberar la memoria que asignamos por medio de malloc?
Echemos un vistazo más de cerca a la línea siete de memory.c.
Hagamos exactamente eso.
La línea 7 de memory.c, Ok, aquí es donde está malloc la memoria.
Por comentarios en help50, al parecer, ¿qué es lo que no hice?
Liberarla.
La liberación es bastante sencilla,
háganla siempre que lo recuerden.
Lo hace con tan solo, pasar el mismo puntero.
No necesitamos recordar cada cuándo es.
El sistema operativo se encarga de recordar cada cuando es.
Ahora, si hago make memory.
Vuelvo a escribir: valgrind ./memory, Enter, un resumen apilado,
se liberó todo el montón de bloques.
No hay posibles fugas.
No veo nada particularmente preocupante.
Ahora el programa está libre de errores.
Valgrind es otra herramienta en nuestra caja de herramientas
que no nos ayuda a encontrar los errores lógicos, per se.
Ayuda a encontrar errores relacionados con la memoria, que serían lógicos.
Nos ayuda a afinarlos y verlos de una manera contraria
como humanos, si se enterraron bajo muchas, muchas líneas de código.
Notemos, brevemente, en la salida de Valgrin,
en varios ejemplos, hay todos estos números raros.
Si vuelvo a la versión original, de hace un momento,
que estuvo lleno de errores de diferentes formas.
Al volver a ejecutar make, y volver a ejecutar valgrind,
veremos un montón de cosas como esta.
En OX tal y tal, en OX tal y tal, en OX tal y tal, ¿qué denotó OX la última vez?
El hexadecimal, que de manera breve, es una
manera extraña de representar números, en general ubicaciones en la memoria.
De manera específica, dice que la línea 13 de memory.c
pasa a usar memoria en esta ubicación.
No es tan útil para nosotros los programadores.
Pero, es por eso que lo vemos.
Valgrind, sin duda, es una herramienta más avanzada,
que dice las ubicaciones en la memoria, en las herramientas e incluso en depuradores
que tiende a escribir con notación hexadecimal como esa.
Desde luego, que vieron hexadecimales convertidos.
Como esos primeros tres bytes en un JPEG,
que normalmente piensa en usar hexadecimales como este.
A pesar de que esto parece nuevo, es exactamente la misma idea.
Pensé que quizás provocaría con una broma
que solo un especialista en sistemas entendería.
Esa es una buena que da la vuelta cada año.
Por supuesto, alude solo a estas direcciones.
Propondré otra técnica de depuración y explicaré como,
qué diablos pasa en el escenario hoy.
Tenemos debug50, que es una herramienta para depurar y recorrer
a lo largo del código.
Y aunque esto es tonto, en realidad hay
esta cosa en el mundo de la programación, el pato de goma de la depuración.
Esto es, en ausencia de un TF o un CA para rebotar ideas,
esto es en ausencia de un compañero de cuarto o compañero de cuarto alrededor
que quiera hablar contigo sobre su código.
Se recomienda que si tienen algún error en su programa,
que guarden algo como esto en su escritorio.
Y en ausencia de compañeros de cuarto y amigos
y con suerte a puertas cerradas, hablen con el pato de goma.
Me siento tonto diciendo esto, pero hay un artículo de Wikipedia
sobre esto, es una cosa real.
La idea aquí es que si alguna vez han estado en horas de oficina
o chatean con un amigo TFer
es como hablar de tu código y hablar sobre qué
es lo que crees que hace tu código, muy a menudo ese acto
de decir algo y escucharte a ti mismo decir eso puede ayudar a reforzarlo,
lo que tu código hace.
O si se dan cuenta verbalmente, espera un minuto.
Lo que dije no parece alinearse con el código,
finalmente esa bombilla se apaga.
No tiene que ser un pato.
Quiero decir, pueden hablar con la pared, pero eso es un poco extraño.
Al menos esta es una personificación de tener a alguien como un colega
para hablar.
Hoy al final o durante el descanso por todos los medios,
tomen un pato de goma el depurador y guárdenlo en tu estante.
No tiene que ser tan grande.
Esta es una técnica de depuración genuina.
En la falta de comprensión de algo,
no necesariamente se dirigen al discurso CS50 o a ​​las horas de oficina
o a secciones, o similares.
Intenten hablar consigo mismos a través de eso,
incluso si se sienten un poco tontos.
Si se sienten tontos, solo mírenlo
y hablen consigo mismos tal vez en su cabeza.
Ese tipo de enunciación de lo que su código hace o debería hacer
con suerte ayudará a que las bombillas se apaguen.
Finalmente, pueden mantenerlos en su estante
y también quitar esa rueda de entrenamiento.
Tomemos nuestro descanso de cinco minutos.
Si desean, tomen un pato y volveremos con más.
Muy bien.
Volvimos, y seguimos pensando en la memoria.
Esto en general se presenta como tener direcciones,
pero, por supuesto, aclaramos un poco que tenemos
más de un lienzo a nuestra disposición.
Ahora, hablamos sobre tener cosas de vuelta
para recordar en la memoria.
Eso no necesita ser el caso.
Como lo que ahora tenemos con los punteros y con malloc,
este tipo de funciones son capaces de obtener memoria desde cualquier lugar
que queremos y de alguna manera, las unimos o conectamos estas cosas.
¿Cómo lo hacemos con los ingredientes que tenemos?
¿Por qué deberíamos querer?
Es así como representamos algo similar a un arreglo.
Un arreglo es un fragmento contiguo de memoria
donde almacena cosas una tras otra, tras otra, tras otra.
Supongamos que puse seis cosas en este arreglo, seis números, uno, dos,
tres, cuatro, cinco, seis.
¿Qué sucede si trato de poner siete en este arreglo?
¿En qué tengo que pensar o preocuparme?
Tocando la memoria que no se me permite tocar.
Será mejor que no lo ponga aquí.
¿Y si siete deben ir en este arreglo?
Bien, no tengo muchas opciones.
Si lleno el espacio, debo sobrescribir algún valor o ponerlo
en algún lugar donde no debería estar, y eso nunca debería
ser una opción porque el programa podría o se bloqueará.
De manera alternada, asignaría más memoria.
¿Cómo hago eso?
Si al inicio asigné este arreglo para que fuera de tamaño seis,
Podría codificar y asignar un nuevo arreglo de tamaño siete y hacer, ¿qué?
PÚBLICO: [INAUDIBLE]
DAVID: Sí, agrego todos los números y siete.
Tomaré este arreglo, puedo darme otro en otra parte de la memoria,
copio los valores de antiguo al nuevo, después quizás libere el anterior.
Sigo con mi vida porque ahora tengo suficiente memoria.
Eso soluciona el problema.
Si lo implementamos en el código de manera correcta,
sería correcto, suponiendo que hay suficiente memoria en la computadora.
¿Por qué, sin duda, es un mal diseño?
PÚBLICO: Es una pérdida de espacio.
DAVID: ¿Cómo qué es una pérdida de espacio?
PÚBLICO: [INAUDIBLE]
DAVID: Sí, aunque no me quedo con ambos en la historia
Digo, es bastante ineficiente en el sentido de que
uso el doble de memoria de la que necesito solo para reducirla.
¿Qué otra cosa?
Sí.
PÚBLICO: [INAUDIBLE]
DAVID: Sí, sí quiero cambiar el tamaño del arreglo otra vez,
ya sea más grande o más pequeño, o si elimino elementos de la lista,
Tengo que asignar memoria nueva, lo cual es un desperdicio
y más importante, es espacio ineficiente.
¿En qué otro sentido es ineficiente?
Tiempo, ¿por qué?
¿De dónde viene el tiempo?
PÚBLICO: [INAUDIBLE]
DAVID: Esa es una notación asintótica.
Al copiar algo de un arreglo a otro sería en gran O ¿de qué?
Van de lado, sí.
Si hacemos el tamaño genérico como n, es como una gran O de N.
Es una operación de tiempo lineal, que no es horrible, ¿cierto?
N al cuadrado está mal.
Lineal nunca ha sido malo, pero ya sabemos que log n es mejor,
el tiempo constante es mejor
Al perder cualquier cantidad de tiempo no se siente como un diseño óptimo.
Que todo esté en función de los arreglos, que sean
de un tamaño fijo y contiguo en la memoria, de manera consecutiva.
Se diría que hay otro problema que podría ocurrir.
Que no es tanto si tienen un arreglo muy pequeño.
Pero, qué pasa si tienen una gran cantidad de memoria disponible
pero en tamaño de cinco o seis incrementos?
Por cualquier motivo, su computadora
utiliza parte de esta memoria, esta memoria, esta memoria, esta memoria,
y si sumamos toda la memoria disponible, hay mucho espacio libre
pero siempre están separados por la memoria que está en uso.
Tal vez esta memoria esté libre, hay un poco que está en uso.
Esta memoria está libre, hay un poco en uso.
Nuestra memoria está, entre comillas, muy fragmentada.
Tenemos mucha memoria disponible pero no es contigua.
No podemos, en este modelo, asignar un arreglo de tamaño siete si no tenemos
esa memoria continua disponible.
No es una gran preocupación dada la memoria suficiente,
a menos que algo pudiera surgir.
A continuación, presentaremos la solución.
Algo denominado una lista vinculada.
El tipo de nombre describe lo que es.
Es una lista de números, pero vinculada por medio de estas flechas.
Ya usamos estas flechas antes.
En las imágenes pasadas, ¿qué representamos con las flechas?
Sí, punteros.
Ahora que tenemos la expresividad de los punteros, de tipo digital podemos
unir nuestras estructuras de datos si gastamos un poco más de memoria.
No resolvimos el problema que identificamos,
cual fue el uso del espacio
Si somos tolerantes con eso y si
tenemos suficiente memoria a nuestra disposición y podemos
darnos el lujo de gastarlo, ¿por qué no almacenamos para cada número
no números, también el espacio para un puntero?
Cada una de las cajas que dibujé aquí ahora no solo
tienen un cuadro para el número n en sí.
En realidad tiene dos cajas juntas, una para n y una para algo
que a continuación, será un puntero
o de manera equivalente la ubicación del siguiente nodo, la llamaremos,
la siguiente caja en esta lista.
A pesar de que lo dibujamos aquí con mucho ingenio de izquierda a derecha,
técnicamente estas cajas estarían en cualquier lugar de la memoria,
específicamente en el montón, proseguiremos a ver.
Pero no tienen que estar consecutivos.
Por el hecho de que haya estos espacios entre los nodos
deliberadamente se dibuja una imagen de que estas cosas no tienen
estar consecutivas para no retroceder más.
Pueden estar en cualquier lado.
Ahora, supongamos que tengo estos cinco números, del nueve al 34.
Supongamos que quiero agregar otro número.
¿Dónde los coloco?
Parece que no tienen espacio.
Pero con base en esta imagen, ¿en cuánto deducimos que diseñaremos esto?
PÚBLICO: [INAUDIBLE]
DAVID: Sí, ¿por qué no asignamos espacio para otro?
No ajustaría en el tablero, pero, ¿a quién le importa?
Podemos ponerlo aquí.
Coloque el nuevo número y agregue una línea, una flecha de aquí a allá.
Esto será lo que llamamos una lista vinculada,
y nos da dinamismo.
Nos da la capacidad de crecer o reducir la estructura de datos, direccionando
nuestro interés, no necesariamente el suyo.
Todavía es un desperdicio de espacio, pero obtenemos beneficios para ambos,
nuestros intereses con el tiempo son mucho mejores porque no
perdemos el tiempo copiando los valores.
Solo agregamos los valores.
Si queremos sumarlo o restarlo, solo hacemos tanto trabajo
como tratar de sumar o restar.
No tenemos que preocuparnos por arreglar todo.
Pero hay algo de complejidad aquí.
Y dado que tenemos un montón de estos,
lo que haría que los problemas en cinco fueran un poco más fáciles,
si aún no hemos acabado, ¿quién lo hizo?
¿Conseguiremos a seis voluntarios para una demostración?
¿Seis voluntarios?
Bien.
Vengan aquí, justo enfrente.
Muy bien.
Bien, vengan a aquí.
Ven, tenemos dos, tres, por allá, cuatro.
Nadie de aquí hoy.
Ok, Ok, cinco.
OK, seis.
Ok, seis, seis.
Vengan.
Muy bien.
Guardaremos esto hasta el final.
Chicos les daré esto, mientras tanto, estos números.
Si no les importa sostener los números.
Quiero que vayas allá a la izquierda,
solo ordénense a sí mismos como en la imagen en la pantalla, Ok serás 17.
Veamos, veamos, nueve.
Pude darles demasiados números.
Ok, separaré eso y te daré el nueve en su lugar, si es posible.
Y te daré, veamos.
¿Tienes el 17?
OK, 17.
Sí, haré que se den la vuelta, si no les importa.
22, y después 26.
Y, ¿qué tenemos?
20, 34.
34.
OK, chicas ustedes serán un poco especiales.
¿Quién quiere ser primera?
OK, así que serás primera.
Y, ¿quién quiere ser temporal?
OK, serás temporal, muy bien.
Vengan acá.
OK, vengan aquí, y si ustedes pudieran acercarse un poco más.
Entonces, tenemos 9, 17, 22, 26, 34, y sepárense como un pie entre ustedes.
Usen sus brazos izquierdos para representar y visualizar a los punteros,
es decir, quién está vinculado a quién.
OK, por qué no solo señalas, muy deliberadamente hacia abajo.
¿Cuál es tu nombre?
NAZLI: Nazli.
DAVID: Nazli.
La mano izquierda de Nazli será un puntero nulo.
No está apuntando a nadie,
solo señala hacia el suelo, como si estuviera electrificado.
OK.
Ahora solo tenemos que tener un primer nodo.
¿Cómo te llamas?
OLIVIA: Olivia.
DAVID: Olivia es un poco especial, en su papel tiene una palabra
y no solo un número.
Ella representa una variable distinta llamada primero.
Porque esto captura a la lista enlazada,
es eso que no recuerdan, a modo de la ubicación de un contiguo
fragmento de memoria.
Recuerden una lista vinculada por medio de la ubicación del primer nodo en la lista
vinculada.
Y, ¿cuál es tu nombre?
ACHMED: Achmed.
DAVID: Achmed.
Así que, Achmed es el primer nodo en la lista en este momento.
El brazo izquierdo de Olivia estará apuntando
a Achmed para representar que él es el primer nodo en la lista.
OK, ¿cuál es tu nombre?
JESS: Jess.
DAVID: Jess.
Usaremos a Jess en solo un momento para completar algunas operaciones.
Supongamos que queremos insertar algún valor en esta lista,
como el número 55.
De acuerdo, el número 55 requerirá un poco de inteligencia
aquí.
Necesito un lugar para almacenar esto.
Necesito un malloc.
Muy bien, eres una voluntaria.
¿Cómo te llamas?
STELLA: Stella.
DAVID: Stella, sube.
Así que Stella es malloc.
Almacenaremos el número 55 en el nodo de Stella.
Si pudieras apuntar tu mano izquierda a cualquier lugar.
Es una especie de valor de basura.
Ok, gracias.
Otra vez, ¿cuál es tu nombre?
JESS: Jess.
DAVID: Jess.
OK, Jess nos ayudará a encontrar el espacio correcto.
Obviamente podemos ver a dónde pertenece 55 si mantenemos esto ordenado.
Pero las computadoras no tienen ese lujo.
Además, ya no tenemos acceso aleatorio.
No podemos solo brincar al [0], [1], [2] porque existen estos huecos entre
ellos.
Y para que quede más claro, ¿pueden todos los demás
dar un paso adelante o atrás para que se vea un poco raro?
Ya no podemos indexar en esta estructura de datos
porque, una vez más, no es un arreglo.
No es consecutivo.
Estas cosas podrían estar en cualquier lugar de la memoria y son los punteros
que están vinculando a todos juntos.
Jess ahora señalará al punto inicial exactamente lo mismo
que Olivia señala en la misma dirección o Achmed.
De acuerdo, ahora tenemos un poco de redundancia.
Pero supongamos que queremos insertar 55.
¿Qué tipo de lógica, cuál es el pseudocódigo aquí para que Jess
encuentre la ubicación de Stella?
¿Qué debería hacer Jess?
Sí
PÚBLICO: [INAUDIBLE]
DAVID: OK, continúa hasta que ella encuentre el puntero nulo,
o específicamente, hasta que encuentre el puntero nulo o la ubicación correcta
para este número, si queremos mantenerlo ordenado.
Así que hagámoslo.
Entonces estás señalando a Achmed.
El número nueve no es mayor que 55.
Entonces Stella proviene de Achmed.
¿Qué es lo que haremos?
Bien, señala a María.
¿Por qué sabes que señalarás a María?
JESS: Porque nueve es menor que 55.
DAVID: Nueve es menor que 55, también, Achmed no es solo la historia nueve.
Correcto, tienes este próximo puntero que dice
Jess donde está el siguiente valor para mirar.
Entonces su mano izquierda es el sustituto de lo que sería ++ el mundo
de un arreglo.
Recorrerás físicamente y recorrerás a lo largo de esto.
Entonces, señalaremos aquí al 17.
No es mayor que.
A continuación, señalamos a Arunev.
OK, eso es un 22.
Todavía no es el valor correcto.
Seguimos adelante.
¿Cuál es tu nombre?
¿Jeung Wan?
OK, ese no es el número correcto porque sostiene 26.
Y ahora, ¿te atrapamos otra vez?
Nazli.
Todavía no está bien, y ahora seguiremos su mano izquierda.
OK, sabemos que este tiene que ser el espacio correcto porque no
encontramos el espacio numérico correcto.
Si podemos prestarte, Stella, todo el camino hasta aquí.
Técnicamente, no te mueves físicamente en la memoria
pero esto solo mejorará la historia.
OK, estamos re-distribu-yendo algo.
¿Qué haremos ahora con Stella
que encontramos la ubicación correcta?
Déjala aquí, OK.
Ahora, ella está como huérfana.
Ella señala a la nada y nadie la señala a ella.
Es un poco triste
Esto es perfecto.
Pérdida de memoria.
OK, entonces lo arreglaremos.
¿Quién tiene el punto de quién?
Ok, bien.
Y ahora, ¿qué debería señalar Stella?
Desde ahora ella está al final de la lista y ella solo
apuntará a algún valor de basura.
Ella señala, para ser claros, a algún valor de basura porque cuando llamamos a
malloc solo obtenemos valores de basura.
Anulamos uno de esos valores de basura con 55 para n,
pero aún no se sobrescribió el puntero.
Entonces, ¿qué quieres hacer, Jess?
¿A quién?
Está bien.
Está cerca.
¿Cuál debería ser su valor si no hay nadie a su izquierda?
Debería ser nulo.
OK, ¿antes, cómo representamos nulo?
Si, exacto.
Nulo, ahora que tenemos una lista, y solo para arreglar las cosas, su puntero,
entonces, Jess es algo temporal.
No nos importa cuál es su valor.
Pero, ¿quién es importante aquí?
Otra vez, ¿cuál es tu nombre?
Olivia fue la primera y, ¿tenemos una lista que todavía está vinculada?
La tenemos.
Desde luego, tomó un poco de tiempo pasar por esto.
Y dije un tipo de mentira.
No hice esto más rápido porque,
¿qué sucedió con el tiempo de ejecución de este algoritmo?
Todavía era un log de n.
Pero eso es porque, ¿qué?
¿Qué propiedad traté de mantener?
El orden.
Entonces supongamos que relajo esa restricción.
Supongamos que no me importa que pertenezca a un orden establecido.
¿Puedo hacer algo mejor que 0 de n para insertar a Stella?
PÚBLICO: [INAUDIBLE]
DAVID: OK, tiempo constante, ¿dónde puedo ponerla?
PÚBLICO: [INAUDIBLE]
DAVID: Exactamente,
Si no nos importa el orden establecido,
me habría ahorrado una gran cantidad de tiempo
y pudimos insertar a Stella aquí, las manos actualizadas de Olivia y las
manos actualizadas de Stella apuntan a Achmed.
Y entonces, lo repetimos.
Tiempo constante.
Y aquí, hay un ejemplo de por qué una gran O de uno no significa un paso.
Es tiempo constante porque es como mover la mano de Olivia y la de Stella
pero no la de Achmed.
Eso son al menos dos pasos, son siempre dos pasos.
¿Podríamos obtener una ronda de aplausos para nuestros voluntarios?
[CHEERING] Pueden conservar tanto sus números, como estos, si lo desean.
Muchas gracias.
Tal vez esto le ayude a tu P a establecerla en cinco.
Oh, lo siento.
Está bien, gracias.
Esa, desde luego, es solo una operación.
Pudo haber otros números.
Si intentamos insertar cinco en el orden establecido,
conseguimos nuestro tiempo constante o nuestro omega de uno
porque en el mejor de los casos, el número terminaría al principio.
20, si lo hubiéramos insertado con nuestros humanos, pudieron
estar un poco más involucrados, porque habrían recorrido la lista
como lo hicimos con Jess.
Ella tiene que señalar a la persona detrás
y a la persona que tiene delante porque tiene que actualizar más manos.
Esto quiere decir que, el realizar inserciones o incluso eliminaciones
requieren de unos pocos ajustes.
Es como arreglar ropa, en la que intentamos
mantener un proceso contiguo a lo largo de todas las estructuras de datos.
A fin de cuentas, a pesar de que usamos punteros,
todo se reduce a tener esta lógica correcta.
Continuaré con un ejemplo de algún código sobre cómo hacer esto.
Proseguiré, abriré la lista 0.c
y echaré un vistazo sobre cómo funciona esto.
En la lista 0.c tenemos el siguiente código.
Primero, en main, obtenemos del usuario un número positivo.
Agitaré mis manos, porque esto es algo así como la vieja escuela.
Usamos un bucle while para obtener un número positivo del usuario.
Yo lo llamo capacity.
Este es un ejemplo de cómo obtenerlo del usuario, al llamarlo capacity.
Capacity, significa el tamaño máximo posible para una estructura de datos.
Eso sería un término artístico.
Por la semana dos del curso,
asignamos un arreglo para ints de muchos ints de capacidad.
También, afortunadamente es familiar.
Aún no hay punteros.
No hay nada demasiado elegante.
Estoy asignando un arreglo de tamaño con base en lo que un humano escribió.
Aquí es donde se pone un poco interesante.
El propósito de este programa es solicitarle al usuario muchos números
una y otra y otra vez.
Puedo escribir 1, 2, 3 o 5, 17, 20, 22, y así sucesivamente.
Y construir un arreglo de números y memoria,
pero tropezaré con el problema que identificamos hace un momento.
Aquí vamos.
Inicializo el tamaño a cero porque inicialmente no hay
nada en la estructura.
Si bien, el tamaño es inferior a mi capacidad, mientras que el tamaño actual
es menor que el máximo, por así decirlo, haga lo siguiente.
Primero, obtendré un número del usuario.
El objetivo es insertar este número en esta lista.
Ahora haré una verificación de sanidad rápida.
Verificaré y me aseguraré de que este número ya esté, o no, en la lista,
porque no quiero tener duplicados.
¿Por qué?
Simplemente porque
Quiero que esta sea una lista muy limpia, sin duplicados.
Este bucle de código, tal vez de la semana uno, semana dos, semana tres,
es solo un ejemplo de iterar a lo largo de un arreglo, buscando el número,
y si es así, recordaré que lo encontré con un booleano,
así que, encontré una respuesta o no, con una variable booleana.
OK.
Eso es todo lo que hace este código.
Todavía no hay magia.
Ahora, aquí es donde sucede la parte interesante de la historia.
Si el número no se encontró en la lista, aquí estará cómo por una semana o dos,
agregamos un número al final de un arreglo.
Porque, si el tamaño inicial es cero, los números [0]
es donde debe ir el primer número.
Si el tamaño inicial es uno en este punto, los números [1]
es donde debe ir el primer número.
Y entonces, debería incrementar el tamaño.
Aquí hay un problema, porque una vez que imprimo estos números
y el programa termina. Puedo ingresar tantos números
como hay disponibles, como la capacidad que tengo.
Por lo tanto, es un poco limitado.
¿Y si quiero hacerlo un poco mejor que esto?
En list1.c que introduce material nuevo, o al menos una aplicación
de los temas esta semana y la última.
Aquí en la línea nueve es técnicamente cómo puedo asignar
un arreglo antes de saber el tamaño del mismo.
Un arreglo, recuerden, es un fragmento de memoria
identificado por alguna palabra, como números o estudiantes o cualquier cosa.
Pero, hemos visto que hay una especie de equivalencia, donde
si un arreglo es un fragmento de la memoria, se puede referir a el
por su ubicación, la dirección de su primer byte como una cadena.
Esto en la línea nueve es un poco nuevo,
pero es como esa idea.
Dame un puntero llamado numbers pero inicialízalo como nulo.
No hay espacio para el número.
Pero este puntero, por lo tanto, no apunta a ningún fragmento de memoria.
Sería como si Olivia se parara aquí incómodamente sin nadie
a quién señalar porque solo asignamos espacio para el primer puntero,
no para todos los demás en el escenario.
Su capacidad predeterminada es cero.
El resto del programa es bastante similar.
Continúo y le pregunto al usuario infinitamente un número.
Verifico si hay errores.
Si leemos la documentación
para obtener int, devolverá una constante especial llamada INT_MAX
si el usuario se detiene para escribir la entrada.
Aquí verifico con un bucle booleano,
si este número está o no en la lista, igual que antes.
Aquí es donde usaré algunas nuevas funciones.
Si el número no se encontró en la lista, y el tamaño de la lista
ya es igual a su capacidad, es decir, si está lleno.
Conceptualmente, ¿qué tengo que hacer ahora?
Si tengo un arreglo cuyo propósito en la vida, como lo hicimos anteriormente,
¿solo aumentará?
Necesito agregarle espacio.
Necesito agregar espacio, como propusimos.
Aunque esto es una especie de debilidad, porque es un poco ineficiente,
así es como podemos hacerlo.
Puedo llamar a realloc, nombrando al arreglo cuya memoria
quiero reasignar.
Solo digo a realloc ahora cuánto espacio quiero.
Este es el tamaño del int, cuatro bytes. (sizeof(int))
Y este es el número de bytes que quiero. (size + 1)
Cualquiera que sea el tamaño actual, realloc me dará un byte más.
Y luego realloc se asigna a numbers,
y verifico si es nulo o no.
Lo mantengo un poco simple.
Agregaríamos verificaciones de errores, pero, ¿qué significa lo que realloc
hace?
Realloc es bastante genial porque si pasamos a realloc,
un puntero a un fragmento de memoria que es de este tamaño, realloc
buscará en la memoria de su computadora y si
ve un fragmento de memoria más grande,
se encargará de copiar todo para usted.
Después, regresó a la ubicación del nuevo fragmento de memoria
y liberó el antiguo para nosotros.
También hace el intercambio.
Todavía es tiempo lineal, pero así es como lo usaríamos
sin asignar, liberar y usar un bucle for como lo describimos anteriormente.
Podemos continuar y poner el número en la lista como antes.
Lo único nuevo aquí, aunque lo estamos pasando rápido,
es que así es como llamamos a realloc.
Pasamos un puntero que apunta previamente
a un fragmento de memoria o incluso nulo.
Está bien.
Si lo pasamos en un puntero que señala nulo,
le devolverá la ubicación de solo un byte, y la siguiente vez
dos bytes, tres bytes y cuatro bytes.
Con listas vinculadas, las cosas se ponen un poco más interesantes.
Y la sintaxis será un poco extraña, pero veamos.
Podemos implementar a cada uno de nuestros voluntarios humanos
un código.
A cada uno de ellos lo llamé nodo, nodo es un término artístico en CS.
Se refiere a cierta estructura de datos que contiene cierta información.
Cada uno de ellos sostenía un número, que llamamos int.
Cada uno de ellos, es un poco raro,
tenía una mano izquierda llamada siguiente, que pretendía apuntar a alguien
que se parecía a ellos estructuralmente.
La idea aquí es que no queremos tener otra estructura
dentro de una estructura, de lo contrario haría
que sucediera algo similar a las muñecas rusas infinitas.
En su lugar, quiere decir que cada una de estas estructuras
tiene un puntero hacia otra persona que se parece a ella estructuralmente.
Así es como obtenemos la metáfora del brazo izquierdo implementada en el código.
Eso solo define un nodo, uno de nuestros voluntarios.
Así es como implementaríamos a Olivia en una línea de código.
Olivia era ella misma un puntero hacia un nodo.
Ella no tenía un número, ¿cierto?
Su cartel solo decía primero,
ella no sostenía un número.
Entonces, no necesitamos una estructura completa para Olivia,
solo necesitamos un puntero unido a una de esas estructuras de nodos.
Inicialmente ella solo se paró aquí, así que
se dice que ella fue nula desde el inicio.
El resto de este código es sobre malloc-ing, o alguien como Stella
que actualizó a Olivia y usó a Jess para actualizar los punteros
temporalmente.
Veamos cómo se ve esto en el código.
Aunque es cierto, solo le pediré al usuario los números.
Como antes, verificaré los errores de la misma manera.
Aquí es un poco diferente.
Este es el bloque de código con el que compruebo si mi lista enlazada actual ya
tiene el número que intento insertar.
Pero recuerden, le quitamos la expresividad de los corchetes.
Ya no podemos hacer eso.
Ahora tengo que hacerlo con punteros.
Así que, aquí vamos.
Con mis cuatro bucles, inicializo un puntero, Jess,
para apuntar a lo mismo que apuntaba Olivia, números.
Así que de nuevo, Jess también era solo un puntero.
Ella no sostenía un número.
Ella estaba sosteniendo un PTR, por lo que era solo un puntero
que apuntaba a lo mismo que Olivia.
Aquí estamos diciendo, siempre y cuando Jess no sea igual a nulo,
siempre que Jess no se aleje del escenario,
haz lo siguiente.
¿Qué quiero hacer?
Y esta sintaxis es nueva.
Vimos al inicio el operador punto,
que dice, toma una estructura de datos, por ejemplo estudiantes
y entra con el operador punto.
Obtén su nombre y su dormitorio.
Eso fue porque la primera demostración de hoy no usó punteros.
solo utilizó estructuras.
Ahora estamos utilizando estructuras y punteros
y entonces la sintaxis cambia solo un poquito.
Cuando tienes un puntero que es un puntero a una estructura
y quieres seguir ese puntero e ir a esa estructura,
la única sintaxis en C que tal vez corresponda con esa realidad o concepto
es este operador de flecha, lo cual significa seguir la mano izquierda,
ver la estructura y conseguir ese número.
Entonces, si el número del voluntario es igual al número que Jess estaba buscando,
sigue y dice que lo encontrado es verdad.
De lo contrario, actualiza a Jess o al puntero a igual
sin importar a lo que apunte su mano izquierda.
Entonces, si Jess estaba señalando temporalmente aquí,
entonces ella se actualizaría apuntando allí.
Y eso es todo lo que hace este código.
Jess comienza a señalar a lo que apuntaba su mano izquierda.
Se mueve físicamente en el escenario.
Y bien, ahora es cuando las cosas se ponen feas.
Y haremos esto en otra dirección, porque creo que está mejor
hecha y a un ritmo más lento para nosotros.
Y veremos este tipo de cosas en la sección y más allá.
Así es como asignó el espacio para un nuevo nodo.
Cuando dije malloc Stella, es esta línea de código, la 45.
Malloc espacio para el tamaño de un nodo y almacenarlo en la persona
que Stella incorporó.
De lo contrario, si no hay suficiente memoria, si algo sale mal,
devuelve uno.
Mientras tanto, así es como agregamos el número a la lista.
Así que eso es exactamente lo que Jess actuó.
Primero le dimos a Stella su número, que es la línea 52.
Técnicamente le dijimos que apuntara a un valor basura,
así que he mejorado el código desde entonces.
Entonces la línea 53 sería como decirle a Stella, señala aquí, no acá,
y eso solo es limpiar la omisión de la última vez.
Y luego aquí tenemos el mismo tipo de código
de nuevo, un bucle de cuatro que se ve un poco raro
pero es como actualizar la mano mientras recorre la lista.
Y aquí es donde ocurre la parte interesante.
Al final de nuestra historia, Jess manipuló los brazos de nuestro voluntario.
Entonces, if not pointer next, que es una forma críptica de decir,
if (ptr ->next == NULL)
Entonces, si Jess ha encontrado el final de la lista,
ve y actualiza a quien sea que esté apuntando con la
mano izquierda para señalar a Stella, el nuevo nodo.
Luego sale porque ya hemos terminado.
Así que sintácticamente, esto es difícil y problemático
establecer cinco nos dará la oportunidad de pasar por un código muy similar.
Pero por ahora, dense cuenta de que todo lo que estamos haciendo
es en vez de simplemente usar una aritmética muy simple, más uno, más uno,
más uno, solo estamos siguiendo estas flechas, siguiendo estas flechas.
Y el tipo de sintaxis que usaremos para eso
solo es esta, que no es muy legible a primera vista.
Por eso enfatizo, si son personas más visuales,
los tipos de manipulación de mano y cambios de brazo
que estábamos haciendo físicamente aquí con nuestros voluntarios.
Y luego, nuevamente, imprimimos [INAUDIBLE].
Lo último que haré notar y que
aplicarán en la serie de problemas cinco, es como pueden
liberar una lista completa de números.
Simplemente felicité a nuestros voluntarios y todos
dejaron el escenario, siendo liberados.
Si quisiéramos que fuera más metódico, podríamos usar un bucle de cuatro
pero aquí elegí un bucle while, porque es
un diseño un poco más sucinto sabio.
Aquí estaba nuestro puntero, un puntero temporal que apuntaba a los números.
Y aquí puedo decir, mientras el puntero no sea nulo, porque si fuera nulo
mi trabajo estaría hecho.
Y aquí digo, actualiza este valor siguiente para que sea igual
al que le siga en la lista.
Libera al que actualmente esté en la lista
y luego actualiza el siguiente puntero.
De nuevo, no se preocupen mucho por los detalles de nivel inferior.
Solo den por hecho que ahora tenemos una forma de implementarlo
en el código, la intuición de nivel superior que deriva
de este tipo de estructura de datos.
Pero no se preocupen aún por el código en sí.
Ahora tenemos la capacidad para unir estructuras de datos de esta forma.
El aspecto positivo es que ahora obtenemos dinamismo, ¿cierto?
Ya no estamos estancados pintando por nosotros mismos
en la esquina proverbial con arreglos que no asignan suficiente memoria.
O por el contrario, desperdiciando memoria con demasiadas asignaciones
para no lidiar con el problema.
Pero pagamos un precio con la lista vinculada.
Obtenemos dinamismo y podemos agregar o restar un nodo de manera más eficiente,
y solo tenemos que actualizar los punteros en un tiempo constante.
Pero gastamos más memoria en todos estos mugrosos indicadores.
Y francamente, el código es más complejo.
Recuerden la primera o segunda semana, el tiempo humano, el tiempo del programador
son recursos valiosos.
Y hacer algo más difícil y que consume más tiempo implementarlo
podría no ser un precio que quieran pagar.
Incluso chateaba ayer con un colega
sobre cómo en la escuela de posgrado solía tomar atajos,
especialmente a altas horas de la noche cuando escribía un código.
Y a veces escribía deliberadamente un código realmente malo
que podía tomar hasta ocho horas para analizar
algunos datos para un proyecto de investigación porque, ¿saben?
Me di cuenta de que era más rápido escribir un código incorrecto, mal diseñado,
tomará ocho horas porque en esas ocho horas
francamente podía irme a dormir.
Ahora diría que fue solo porque mi asesor no me calificaba
la corrección del diseño y el estilo.
Pero es la manifestación de un recurso muy real
y no recomiendo que tomen atajos por el momento,
ya que uno de los objetivos de estar en un curso es mejorar en el diseño.
Pero a fin de cuentas y en el mundo real,
incluso el equipo de CS50 y yo tomamos decisiones constantemente.
Bueno, sí, podríamos mejorar esta función de help50
pero tomaría una semana hacerlo.
O simplemente podemos agregar una línea adicional de código y tenerlo listo ya.
Y sería una compensación.
Esto es lo que hace que el código sea bueno y malo.
Y cuando comenzamos a tomar atajos,
empezamos a acumula lo que se llamaría deuda técnica.
Y las deudas tienden a no ser algo muy bueno.
Eso habla del precio que se pagará a largo plazo
porque podría tomar más tiempo este verano, al equipo y a mí,
volver y limpiar todo eso.
Y Dios no lo quiera, durante la noche esto sucedió
con más frecuencia de lo que debería admitir, mi código tenía errores y fue rescatado
a las 2:00 a. m., al levantarme ocho horas después pensando, mis datos están listos.
Y no.
Debí hacerlo bien la primera vez,
así hubiese podido ejecutar el código una y otra vez.
Entonces, ¿qué más obtenemos de esta habilidad
de tener punteros en las estructuras de datos?
Aquí les muestro esta imagen del comedor de Mather.
La tapa representa la noción de bandejas
y hemos usado la pila en un nivel muy bajo
y esotérico para hablar sobre la administración de la memoria,
que no es tan útil para que resolvamos problemas.
Pero la estructura de datos es...
En informática hay una estructura de datos conocida como pila o stack.
Y su computadora, ya sea Mac o PC, lo usan constantemente
para administrar funciones y memoria, pero podemos usarlo también
para varias aplicaciones.
Podemos implementar una estructura de datos en las que hay dos operaciones.
Convencionalmente se les llama push y pop.
Aunque es como sumar y restar.
Pueden llamarlo como quieran pero la mayoría de los programadores
lo llaman push y pop.
Push es como agregar una bandeja a la pila y pop es como quitarle otra.
Pero es tal como su nombre lo indica con la pila,
La característica de una pila es que es un ejemplo de estructura de datos LIFO,
LIFO significa "last in first out", último en entrar, primero en salir,
Ahora, ¿qué significa esto?
Bueno, si uno de los empleados del comedor viene con una bandeja nueva
que acaba de ser limpiada y la pone en la parte superior de la pila,
¿Cuál es la que un ser humano normal agarraría primero?
La última en entrar, ¿verdad?
Sería extraño y difícil ponerse de rodillas
y sacar la de abajo, aunque sería más justo, ¿no?
Como esa pequeña bandeja de abajo que ha esperado más tiempo para ser utilizada,
pero está por debajo del peso de toda la pila, literalmente.
Sin embargo, así es como funcionaría una pila.
Y ahora pueden implementarla de varias maneras.
Y aquí es donde el mundo se vuelve interesante en programación,
es donde hay esta distinción entre el diseño
de estructuras de datos y los detalles de implementación de bajo nivel.
Una pila es como la describí, una estructura de datos LIFO.
Push y pop, último en entrar, primero en salir.
Eso es todo.
Cómo lo implementarían, puede ser de varias maneras.
Por ejemplo, podrían implementar una pila como un tipo de datos C, personalizado,
que tiene un arreglo de números para esta capacidad, donde esta
es una gran constante como 100 o 1000, sin importar cuántas bandejas quiero
almacenar, siempre y cuando realice un seguimiento del número
de bandejas que hay, de modo que siempre me asegure que su tamaño es inferior
o igual a la capacidad.
Solo para estar seguro de que no intenté meter demasiadas bandejas allí.
Pero ¿cuál es un inconveniente de esta implementación
en la pila de bandejas de Mather House?
PÚBLICO: [INAUDIBLE]
DAVID: Espacio limitado, exactamente.
Apliqué conscientemente un hardcode a la capacidad de ser un valor fijo.
Entonces, si compramos una bandeja nueva o una caja completa de bandejas,
podría no encajar allí, ¿cierto?
Una vez que agote este espacio restante, necesito formar una nueva pila
o necesito guardarlas en otro lugar.
Me quedé sin espacio.
Así que tal vez esta es una buena decisión de diseño porque refleja la realidad.
O tal vez es estúpida porque ahora ya no puedo almacenar más bandejas
cuando llegan por envío.
Podría resolver eso.
Sabemos, gracias a nuestro breve ejemplo de hace un momento,
que podríamos hacer que nuestro arreglo sea dinámico.
No preasignarlo para que tenga capacidad de tamaño.
Simplemente declarar que es un puntero que finalmente
señalara el espacio tal vez para una, 100 bandejas,
1000 bandejas, o quizás 1001 bandejas si redistribuimos el espacio una y otra vez.
Y cuando comiencen a escribir códigos que involucren a otras personas, ya sea
para algún proyecto escolar, un proyecto personal o el mundo real,
es aquí donde la vida también se vuelve más interesante,
porque siempre y cuando ustedes y yo, si mi colega decide, OK,
Voy a exponer push y pop como las operaciones.
Implementaré push y pop.
No tienen que preocuparse por los detalles de implementación
de bajo nivel en mis propias decisiones de diseño.
Solo tienen que leer mi documentación y
no importa cómo lo implementé porque lo he separado para ustedes
y les he dado solo una API.
Push and pop sería una API, una interfaz de programación de aplicaciones.
Todo lo que necesitan saber es que pueden confiar
que implementaré push y pop.
Y puede que no les guste, a fin de cuentas, si limito su espacio,
pero para entenderlo necesitan leer la documentación,
para que sepan qué funciones proporciona mi implementación.
Esto, por supuesto, es la ridiculez que se produce cada año, más o menos,
por la cual las personas hacen fila para comprar un iPhone.
Ahora, ¿por qué sería malo si Apple utilizara una pila cuando las personas
llegan a las 3:00 a. m. para comprar sus iPhones?
Sí.
PÚBLICO: [INAUDIBLE]
DAVID: Exactamente,
La persona que llegó al último conseguiría primero su teléfono,
lo cual sería fantástico para esa persona.
pero sería muy injusto para todos los demás.
Así que Apple, como la mayoría de tiendas, si tienen este problema, usan
colas o filas como una estructura de datos FIFO, primero en entrar, primero en salir.
Donde la primera persona en la fila obtiene primero su iPhone.
Entonces, ¿cómo pueden implementar esas operaciones o esa API?
Podríamos llamarlo nq y dq, o sumar y restar, lo que sea,
pero estos son los términos especializados.
Y podemos implementarlos de la siguiente manera.
Una cola puede necesitar solo un poco más de información que capacidad y tamaño
solamente.
Tienen que recordar quién está al frente, potencialmente,
para que cuando esa persona se salga de la fila,
no tengan que mover todos los datos del arreglo
al igual que los humanos caminan hacia adelante.
Eso sería una pérdida de tiempo
cada vez que alguien esté listo para comprar su teléfono,
¿Por qué n-1 personas tienen que dar un paso adelante?
¿Por qué no les traen el teléfono y se ahorra ese uso ineficiente del tiempo?
O podríamos hacerlo así, más como una estructura de datos dinámica.
Y no haremos el código aquí,
pero vimos el ejemplo en nuestra lista cero, uno,
y dos códigos, cómo podríamos comenzar con un arreglo de tamaño fijo,
hacerlo dinámico con malloc y realloc, y cómo podríamos
volverlo dinámico con una lista vinculada, aunque con intercambios de tiempo
y espacio.
Les traigo un genial video corto que pensé en compartir con ustedes, en el que Jack
aprende los hechos sobre las colas y las pilas, que distinguen a estas dos
estructuras de datos de un modo que plasma una imagen más clara sobre el modo
en que son distintos.
Atenuemos las luces durante 60 segundos, más o menos.
[REPRODUCCIÓN DE VIDEO]
Había una vez en la ciudad un tipo llamado Jack.
Cuando de hacer amigos se trataba, Jack no tenía habilidad.
Así que Jack fue a hablar con el chico más popular.
A su amigo Lu fue a ver y le preguntó: ¿qué puedo hacer?
Lu vio que su amigo estaba realmente afligido.
Bueno, dijo Lu, solo mira cómo estás vestido.
¿No tienes ropa decente con apariencia diferente?
Sí, dijo Jack.
Sí tengo, la verdad.
Ven a mi casa y verás qué es lo que pasa.
Entonces fueron a su casa y Jack le mostró a Lu la caja
que usaba para fines, de guardar camisas, pantalones y calcetines.
Lu dijo, veo que tienes toda tu ropa en una pila.
¿Por qué no usas otra cosa, de vez en cuando de esta fila?
Jack dijo, bueno, cuando me quito los calcetines y la ropa,
los lavo y los guardo en la caja, antes de que se hagan una sopa,
Luego llega la mañana y me levanto,
voy a la caja y tomo la ropa que está en lo más alto.
Lu rápidamente se dio cuenta del problema que tenía Jack.
El guardaba la ropa, los CD y los libros en un stack,
cuando tomaba algo para leer o ropa interior,
elegía el libro o la ropa que estaba en la parte superior.
Luego, cuando la desocupaba, la ponía de vuelta en su lugar,
y ese de vuelta significaba, el mismo sitio de donde la fue a tomar.
Conozco la solución, dijo Lu triunfante.
En usar una cola, debes aprender a ubicarte.
Lu tomó la ropa de Jack y en un armario la colgó,
cuando la terminó de vaciar la caja simplemente tiró.
Jack, cuando el día esté por finalizar pon tu ropa siempre a la izquierda
cuando ya no la vayas a usar.
Entonces, por la mañana cuando veas el sol brillar
la ropa de la derecha debes tomar, donde la fila veas acabar.
¿No lo ves ?, dijo Lu, será muy agradable ser tú.
Te pondrás todo una vez antes que ponerte algo dos veces al revés.
Y con todas las colas en su armario y estante, no hechas bolas,
Jack comenzó a sentirse muy seguro con su forma de vestirse,
y fue todo gracias a Lu y a sus colas hechas como las harías tú.
[FINALIZA LA REPRODUCCIÓN]
DAVID: Pero eso no quiere decir que las colas lo son todo
y que las pilas son una mala estructura de datos.
En realidad, cada una tiene sus propias aplicaciones.
Y, de hecho, un uso común para pilas, más allá de la administración de memoria,
como lo discutimos hace un par de semanas, cuando comenzamos a explorar HTML y
programación web, verán que ese HTML, en sí,
es el idioma en el que se escriben las páginas web
que pronto podrán escribir, si no es que ya lo hacen.
Este es un lenguaje que en realidad tiene una jerarquía anidada,
la cual, mediante un navegador, podría usar una pila para analizar el HTML
que compone una página web, a fin de determinar, por ejemplo, si es correcta o no.
Pero hay muchas herramientas que ahora podemos agregar al kit de herramientas.
Y a pesar de que veremos cada una de ellas brevemente, todas
son derivadas de estos dos principios simples, la capacidad para llegar
a estructuras de datos personalizados, dentro de los cuales son punteros,
o la habilidad de unir una cosa a otra.
Aquí hay un ejemplo de lo que un especialista en sistemas llamaría árbol.
En el nodo de aquí hemos dibujado círculos, solo porque sí.
Pero los nodos en un árbol son muy parecidos a un árbol genealógico, donde
cada nodo tiene cero o más hijos o descendientes, tal vez
un padre u otros antepasados.
Y entonces llamaremos cosas como el primer nodo
en la parte superior de una estructura de datos llamada árbol, la raíz del árbol,
aunque crezca hacia abajo como este, como un árbol genealógico.
A cualquier cosa en la parte inferior del árbol que solo tenga flechas hacia dentro
se les llamará hijos u hojas del árbol.
Y esta podría ser una forma de diseñar datos de manera útil.
De hecho, si recuerdan cuando teníamos cosas o números
como este, siempre que lidiábamos con números, palabras o Mikes Smiths,
simplemente los ordenábamos de izquierda a derecha en un arreglo
y luego lo buscábamos en la gran O del fin de los tiempos linealmente, de izquierda
a derecha.
¿Pero fue mejor cuando usamos qué?
Búsqueda binaria, pero para la búsqueda binaria tendría que ser un arreglo
y necesitaría estar ordenado.
Y el problema con el que jamás me enfrenté fue que nunca
agregué otra página a la guía telefónica.
En realidad, nunca tratamos de añadir más números a nuestro arreglo.
Y, sin embargo, hoy identificamos estos problemas tan evidentes con los arreglos,
que pareciera que nos tienen arrinconados.
Si asignamos demasiado espacio, lo usamos todo, luego maldecimos
y deseamos agregar más al arreglo.
Entonces, ¿cómo podemos aún diseñar datos en forma ordenada,
aprovechar algo así como el tiempo logarítmico y dividir
y vencer, pero obtener el beneficio diario del dinamismo mediante el cual
podemos hacer crecer la estructura de datos y reducirla de forma gradual,
sin tener que reasignar repentinamente toda la estructura?
Bueno, en vez de presentar estos números, que están convenientemente
numerados como múltiplos de 11, es decir, 22, 33, 55,
¿qué pasa si los presentamos así en la memoria?
No miraremos al código para hacer esto, pero piensen en cada uno de estos círculos
como una estructura, una estructura de datos, dentro de la cual hay
un int n, ¿y cuántos punteros aparentemente?
Siete en total en la pantalla.
Pero, ¿qué tal dentro de cada nodo, como este de aquí?
Hay un número n, 55.
¿Y qué más?
¿Cuántos punteros?
Solo dos.
Dos como máximo, porque las hojas al parecer
son cero por definición.
Y técnicamente, si no hubiera agregado 22, tal vez podría haber solo un niño.
Esto es lo que llamamos un árbol binario porque cada nodo tiene
como máximo dos niños, 0, 1 o 2.
Y es un árbol de búsqueda binario porque tiene una propiedad especial,
es muy buscable, porque si vemos cualquier nodo, su hijo izquierdo es más pequeño.
Y si vemos a cualquier nodo, su hijo derecho es más grande.
Y esa es una definición recursiva, por así decirlo.
Podemos ver cualquier nodo que tenga el árbol y esa definición es verdadera,
incluso en las hojas, porque es una especie de declaración vacía
decir que es mayor que su hijo izquierdo si no hay un hijo izquierdo.
Es algo trivialmente cierto.
Entonces, ¿qué tiene de bueno esta estructura de datos?
Bueno, supongamos que quiero buscar el número 22.
Como nuestra lista vinculada, y como Olivia es el primer puntero especial,
un árbol binario en la memoria de una computadora simplemente
tiene un puntero especial, llamado root o first, o como sea que quieran llamarlo.
Y si queremos buscar 22. al igual que Olivia y Jess,
podríamos ver aquí.
Y decir, hmm, 55 es mayor que 22.
Entonces, ¿qué camino debo seguir?
El de la izquierda, obviamente.
Y aquí, ya saben, si estuviéramos haciendo esto visualmente
podríamos cortar ese subárbol completamente.
Y verían que la mitad del problema es arrancado como con la guía telefónica.
22 versus 33, por supuesto que este es mayor.
Así que vamos aquí y lo encontramos.
Y en resumen, eso no fue lineal
porque no estábamos buscando todos los nodos.
Y si conceptualmente estábamos cortando el árbol a la mitad,
y a la mitad, y a la mitad cada vez que nos fuimos a la izquierda o a la derecha,
¿Cuál debería ser el tiempo de ejecución de búsqueda en un árbol de búsqueda binario?
Log base 2 de n, o simplemente logarítmico como ya hemos visto.
No es necesario que siempre esté tan bellamente equilibrado.
Eso se elige de forma bastante deliberada.
Pueden ocurrir situaciones perversas donde simplemente cambia
en una larga lista vinculada.
Pero aún será un árbol de búsqueda binario.
Estaba mal construido, pero si al menos mantenemos un equilibrio como este,
podríamos obtener algunos beneficios.
Y así es como implementaríamos el número entero que proponen y dos nodos,
En vez de llamarlo simplemente next,
lo llamaré de un modo más útil semánticamente, left y right.
Y noten que struct node es solo un puntero llamado left,
o que struct node es un puntero llamado right.
Y así es como implementamos esto.
¿Y qué creen que tienen las hojas como valores para izquierda y derecha?
Las hojas del árbol no tenían hijos, por definición,
¿cuál es el valor de izquierda y derecha?
Nulo.
Entonces están simplemente apuntando hacia el piso como valores cero, nulos.
Por lo tanto, no vamos a escribir el código para esto ahora,
pero podemos aprovechar las ideas del cero débil.
Divide y vencerás, búsqueda binaria.
Podemos aprovechar lo que vimos la semana pasada, las ideas sobre estructuras de esta
la memoria dinámica y el heap para
comenzar a construir estructuras de datos como esta, que ahora nos dan dinamismo
que puede crecer y reducirse según sea necesario.
Ahora que han visto el código, aquí podría
hacerse la implementación de una función para el árbol de búsqueda binaria
que, dadas las raíces del árbol, encuentre para nosotros verdadero o falso,
ya sea que haya o no algo adentro.
Entonces, quiero buscar un número n en este árbol.
Esto que está aquí es, de nuevo, un puntero a la raíz
al igual que Olivia era un puntero al primer nodo en la lista vinculada.
Entonces, si el árbol es nulo, devuelve falso porque es
una pregunta tonta.
Si no pasa ningún árbol, claramente no está allí.
Entonces devuelve falso.
Es nuestro caso especial para garantizar que no nos sumerjamos demasiado.
Pero aquí hay una aplicación genial de una idea anterior.
Si n es menor que n en el nodo actual del árbol
y recuerden que la flecha simplemente dice: ve y mira a n,
sabemos que queremos mirar hacia el lado izquierdo del árbol.
Entonces, ¿tenemos un algoritmo para buscar un árbol según un valor específico?
Lo que sucede es que ese árbol ahora es más pequeño porque
es la mitad del árbol que está a la izquierda.
Lo tenemos.
Tenemos una función llamada búsqueda, la cual toma un número como entrada
y toma un árbol como puntero.
No tiene que ser el árbol completo.
Puede ser un subárbol porque, una vez más, un árbol se define de manera recursiva,
ya que, cada niño de la izquierda y de la derecha puede tener hijos.
Entonces, es un árbol más pequeño, pero sigue siendo un árbol.
Así que puedo responder a esa pregunta.
Si n es menor que el valor n propio del nodo actual,
Puedo regresar la respuesta a la búsqueda de llamadas del mismo número,
pero pasando justo por la mitad izquierda del árbol.
Entonces es como la versión en árbol de romper la guía telefónica a la mitad
y buscar solo en la mitad izquierda.
Y quizás podamos adivinar, si seguimos en este punto,
si n es mayor que el nodo actual,
buscaremos a la derecha.
Y así serían tres casos.
¿Cuál es el cuarto caso posible?
Sí, si n es igual al nodo actual.
Y en ese caso, de modo trivial devolverá verdadero.
Eso es algo hermoso.
No tiene una sola perspectiva.
No es obvio cómo funciona a primera vista.
Y no es necesariamente cómodo si no se utiliza tanto la recursividad.
Pero lo que es bello de esto, en especial,
es que, si nos deshacemos de las tontas llaves y de un montón de cosas,
no es realmente intelectualmente interesante,
reducimos el problema a tan solo estas líneas de código.
Comprueba si es nulo, devuelve falso.
Comprueba si es menor que, solo recursa a la izquierda.
Comprueba si es mayor que, recursa a la derecha.
De lo contrario, lo encontraste.
Es la misma idea o pensamiento que nuestro enfoque de divide y vencerás
en el caso de la guía telefónica que recién implementamos
utilizando árboles o nodos unidos entre sí en un árbol.
¿Sí?
PÚBLICO: [INAUDIBLE]
DAVID: Árbol, flecha, n, recuerden que "tree" es un puntero a un nodo,
al igual que Olivia era un puntero a un nodo en una lista vinculada,
eso sería como tener a Olivia aquí parada y en vez
de señalar a una fila de estudiantes, señalara a un árbol de estudiantes
que se abre en abanico de esta manera.
Entonces árbol, podríamos llamarlo como queramos,
solo se me ocurrió llamarlo así, lo representa.
Y mientras tanto, el árbol que queda sería como si Olivia apuntara a un nodo aquí.
De hecho, si Olivia señala la raíz del árbol,
el árbol a la izquierda implicaría ver hacia la mitad izquierda del árbol
o hacia la mitad derecha del árbol.
Si nuestros voluntarios fueran mostrados en el escenario como un abanico,
como un árbol en vez de una lista.
Entonces hemos visto muchos algoritmos que podrían
tener cualquier cantidad de estos tiempos de ejecución.
Y hasta ahora el mejor tiempo de ejecución
ha sido el de los algoritmos más sofisticados.
Pero hemos visto un tiempo constante aquí y allá.
E incluso ahora si queremos insertarlo en una lista vinculada
y no nos importa realmente el orden,
podemos conectar el nuevo valor justo después de Olivia y antes de Achmed,
y obtener nuestro tiempo constante.
Pero ¿no sería genial si más operaciones fueran constantes en el tiempo?
¿Un paso, dos pasos, tres pasos o algún número finito?
Y podemos lograrlo con un poco de razonamiento.
Podemos aprovechar otro tipo de idea familiar de la siguiente manera.
Por ejemplo, aquí tenemos algunos naipes inusualmente grandes,
que solo conocerán si ponen en Google "jumbo cards"
y las buscan en Amazon.
Supongamos que quiero ordenar este mazo de cartas,
Para eso podría pasar el mazo de uno en uno
y ordenarlas, tanto por su palo, como clubes y corazones
como por sus números también.
Pero si son como yo, probablemente intentarán
simplificar un poco el problema.
Entonces, si ven al rey de espadas que puse aquí,
el nueve de espadas que pongo acá,
y el 10 de espadas, casualmente,
que pondré allá.
Entonces, ¿probablemente haré lo mismo que con los corazones?
Saben, creo que no voy a pasar de una a la vez,
sino que voy a clasificar cada una de las cartas.
Aquí está el as de tréboles, así que voy a hacer un tercer montón,
aquí hay un par de diamantes,
y esa será mi cuarta pila,
luego voy a repetir esto, pues es un simple algoritmo
que me hará la vida un poco más fácil en tan solo unos instantes,
una vez que todo esté en la pila correcta.
Pero esto es una noción general de lo que llamamos hashing.
Y no voy a terminar porque sorpresa, sorpresa,
vamos a tener 13 cartas en cada pila.
Pero esta es una noción más esencial del hashing.
Tomamos como entrada algo de la lista de entradas,
lo observamos y tomamos una decisión basada en eso.
Y en este caso, mi valor hash va a ser cero, uno, dos, tres,
dos porque entrará en la pila de corazones.
¿Y qué es una función hash?
Solo es una función en el código o en mi cerebro que
toma una decisión con base en la salida y genera un valor hash que,
en este caso, será esa pila, esa pila, esa pila, esa pila,
o si queremos ser más precisos, cero, uno, dos, tres,
Si esas cuatro pilas se implementan, serán como cuatro arreglos
o algún tipo de pilas.
Parece que estoy haciendo una pila de cartas.
Todavía no termino.
Si quiero ordenar esto más tarde,
todavía tendré que ordenar cada una de las pilas de 13.
Pero de algún modo facilite un poco el problema
en el sentido de que lo extendí hacia cuatro problemas equivalentes,
pero el ingrediente clave aquí es la noción de hash.
Si alguna vez han visto a un profesor o asistente lidiar con esto
al final de una clase donde usan cuadernos azules, si todo un grupo de estudiantes
al final de la hora, va y comienza a entregar sus cuadernos azules
se vuelve un completo desastre.
Y si el asistente o el profesor quisieran organizarlos,
podrían hacer un montón de pilas.
Todos los apellidos con L irían allí,
las E irían acá,
las F irían allá
y en este caso se ordenarían alfabéticamente sobre la marcha, lo cual
facilitaría la resolución de este problema.
Eso es una función hash.
Se toma como entrada el nombre de un estudiante,
se observa la primera letra de su apellido,
y se decide si entra en el cubo cero, uno
o tal vez en la 25 si realmente se usó un hash con base en el alfabeto inglés.
Así que el hash es algo que todos hemos utilizado, aunque nunca
le hayamos puesto ese nombre.
Entonces, ¿cómo podemos aprovechar este tipo de ingrediente
y acercarnos más al santo grial de las estructuras de datos, que
serían un tiempo constante para todo?
Como nada de esto lineal, nada es tiempo logarítmico.
Entonces, supongamos que tenemos un arreglo o una tabla, así lo llamaremos.
Llamaré a esto una tabla hash porque quiero aprovechar
la idea de esta función hash.
Y supongamos que lo que quiero almacenar ahí son solo cosas como nombres
y que quiero almacenar el nombre
de Alice, porque ella devolvió su examen primero.
Entonces aquí podría tener de [0] hasta [25] o, en general, n-1.
Así que habría 26 cubos en total.
¿Dónde creen que podría a Alice?
Tal vez usaría con ella el hash cero porque de Alice
usaríamos su nombre, no su apellido, porque parece
que jamás ha tenido un apellido.
Entonces, su nombre, Alice[0] va allí.
Luego es Bob quien devuelve su examen.
¿Dónde va él?
en [1] y luego tal vez siga Brendan.
Diablos.
No hay lugar para el examen de Brendan.
¿Por qué?
Porque su hash tiene el mismo valor.
Esas cosas pueden suceder.
El hash puede tener el mismo valor
y aquí no habría un gran problema.
Puedo seguir recibiendo diamantes, diamantes y diamantes,
lo cual está bien porque la estructura de datos crece.
Pero es un arreglo, al parecer.
Y podría poner a Alice aquí, podría poner a Bob acá,
pero también debería poner a Brendan acá.
Pero no quiero devolver el examen a Bob solo para aceptar el de Brendan.
Entonces, ¿dónde podría poner a Brendan?
Tal vez haga trampa y lo ponga aquí porque hay espacio, ¿verdad?
Todo es gratuito en esta historia hasta el momento.
Pero luego llega Charlie.
¿Qué hacemos con Charlie?
Ahora Brendan está donde debería estar Charlie.
Creo que tenemos un lío aquí, pero tengo mucho espacio libre
y es probable que no tenga un estudiante, sin ofender, cuyo nombre comience
con la Z o la X, o... algunas de las menos probables estadísticamente.
Entonces, ¿por qué no usamos esos espacios?
Podríamos, pero este es un ejemplo algorítmico
de sondeo lineal, donde linealmente, de arriba hacia abajo, se explora
la estructura de datos para buscar espacio y dejar caer
los valores en el primer sitio disponible.
E inicialmente es agradable, limpio, amigable y eficiente
porque si después quiero buscar el examen de Alice, bum,
estará en la cima de la pila
y Bob, bum, será el segundo en la pila.
Pero Charlie, no estoy seguro dónde debería estar.
Así que finalmente, este enfoque del sondeo lineal
es eficiente en cuanto al espacio, ya que empaqueta todo en la estructura de datos,
pero con el tiempo se convierte en algo lineal.
Si Alice entrega al último su examen, debido a la naturaleza del espacio,
podría terminar en la parte inferior de la pila
y eso no facilitaría encontrarla posteriormente.
Entonces, ¿qué ocurre si cambiamos la estructura de los datos
y usamos elementos de hoy y del pasado?
Usemos, solo porque sí, un arreglo de punteros dibujados verticalmente.
Y luego, ¿Por qué no insertamos los nombres de los estudiantes a la derecha?
Es el fragmento de un texto que explora exactamente esta estructura de datos.
Se llama tabla hash, no con sondeo lineal, sino con
un encadenamiento separado, según el cual la estructura de datos, la tabla hash,
técnicamente es un arreglo.
Esta vez se aumentó a 31, porque el ejemplo del libro era
sobre días del mes para cumpleaños.
Entonces, la estructura de datos no solo tiene un arreglo, sino, ¿qué otra
estructura de datos combina con eso?
Es una especie de lista enlazada.
Lo bueno aquí es que S Adams, Adams, comienza con A en nuestra historia.
Ahora están usando cumpleaños, si leen esto en el contexto.
Pero supongamos que Adams es el único cuyo cumpleaños
es el segundo de algún mes.
Bueno, podría terminar aquí.
Y no hay mucho problema si alguien más tiene el mismo cumpleaños en este ejemplo,
porque podemos recorrer la lista, como lo hicimos con Jess,
y simplemente enlazarlos al final de esta estructura de datos.
O podemos insertarlos desde el principio
y usar algunos cambios constantes de tiempo a la izquierda para ubicarlos.
Sin embargo, la estructura de datos ya no es solo un arreglo.
Es una colección de 31 cubos, cuatro pilas, 26 pilas, 31 pilas,
pero cada una de esas pilas puede crecer verticalmente
por así decirlo, o en este caso lateralmente porque estamos
implementando la idea de estas estructuras de datos
usando ahora una lista vinculada real.
¿Entonces por qué esto es mejor o peor?
Bueno, ¿hay algún límite sobre cuántos estudiantes pueden devolver sus exámenes
o celebrar cumpleaños?
No, porque seguimos creciendo cada vez más y más y más.
¿Por qué eso es algo bueno?
Si quiero buscar a alguien, si sé que su nombre comienza
con la A, o si en el ejemplo del cumpleaños, sé
sé que su cumpleaños es el segundo del mes,
Sé, de forma determinista, que no importa
en cuál cubo estén en el arreglo.
Podrían estar en una larga cadena de gente con nombres o cumpleaños similares.
Pero van a estar allí de manera determinista, predecible,
una y otra vez.
Y lo bello de esto es que, si mi función hash está bien implementada, es uniforme,
por así decirlo estadísticamente, entonces sería bueno si casi todas estas cadenas
tuvieran casi la misma longitud
Sería lamentable si esta cadena fuera enorme
y que luego cualquier otra cadena fuera más corta solo porque tiene
una oportunidad para mejorar el diseño.
Entonces, en términos reales, una tabla hash, cuando se implementa así,
debería disminuir en este caso concreto por un factor de casi 31,
el tiempo que tarda en encontrar a alguien
Entonces el tiempo es 1 dividido por 31 porque si todas las cadenas tuvieran casi
la misma longitud, se dividiría el conjunto de datos en 4 pilas, 26 pilas,
31 pilas, cada una de las cuales es una cuarta o 126,
o 131 del tamaño del conjunto de datos completo.
Ahora asintóticamente, hace un par de semanas,
eso era algorítmicamente irrelevante.
Es una gran O de la misma cosa, por así decirlo.
Pero en términos reales, si se tiene tomar un cuarto de tiempo,
126 la cantidad de tiempo, 131 el tiempo real
nos ahorra tiempo en nuestros relojes.
Así en tiempos humanos reales ahorrará tiempo.
Y, de hecho, lo que verán en la serie de problemas cinco, en el cual implementarán
su primer corrector ortográfico, verán que eso es
lo que estamos tratando de optimizar
De hecho, como un adelanto antes de ver nuestra estructura de datos final,
serán desafiados como parte de este problema establecido opcionalmente
si desea participar y competir en el la pizarra.
Una vez que su código esté funcionando por check 50, podrán
ejecutar un comando separado de check 50 para publicarlo en el panel principal
aquí.
Y ahora, diablos, Brian nos está dando a Doug y a mí
porque su implementación del corrector ortográfico
toma solo 4.81 segundos y 7.4 kilobytes contra mis 82 megabytes
de memoria al implementar un corrector ortográfico sobre todas las palabras.
¿Pero cómo se decide la forma de minimizar el espacio o el tiempo,
y cómo se atenúan algunas de las compensaciones?
Bueno, consideremos una estructura de datos final.
Quizás es la más sofisticada y ocupa más espacio,
y hace que sea difícil pintar en la pantalla.
Pero supongamos que hicimos esto.
Supongamos que tratamos de almacenar los nombres en nuestra estructura de datos.
Podríamos hacerlo con un arreglo de muchas cadenas,
y podríamos hacer búsquedas lineales, y Brian o Doug
o yo podríamos usar la búsqueda lineal en la gran O y encontrar la que deseen.
Eso no es muy bueno.
De algún modo podríamos usar la búsqueda binaria si usáramos un árbol o un arreglo
pero mantuviéramos los nombres ordenados.
Sabemos que podemos hacerlo mejor.
Así como encontramos a Mike Smith bastante rápido en la semana cero.
Pero, ¿y si pudiéramos encontrar nombres en tiempo constante?
Por lo tanto, no importa cuántas palabras hay en el árbol y no importa
cuántas palabras hay en el diccionario en general,
¿todavía invierto la misma cantidad de tiempo para encontrar a alguien?
¿Y no se hace más y más largo entre más nombres agreguemos?
Así que aquí hay un tipo de árbol que chistosamente se llama trie, TRIE,
un fragmento de la palabra "retrieval", lo cual es extraño porque es "retrieval"
no "retrival", pero esto es un trie, TRIE.
Cada uno de los nodos de un trie, esencialmente, son un arreglo en sí.
Técnicamente son una estructura con algo más dentro de ellos.
Y lo verán en el recorrido que Zamyla preparó.
Pero cada uno de los nodos del trie es un arreglo.
Cada uno de esos elementos de arreglos es un puntero a otro arreglo de ese tipo.
Y la forma en que se almacenan palabras en un trie no es con caracteres,
sino implícitamente con punteros.
Entonces, si queremos poner el nombre de alguien como Maxwell aquí,
hacemos un hash en este trie usando la primera letra del nombre de Maxwell,
la cual, por supuesto es m.
Y ese será el elemento 13 del arreglo en mi arreglo de 26 elementos
que tengo aquí.
Voy a cambiar ese puntero, originalmente nulo,
para que sea un puntero a otro nodo.
Y luego haré un hash con la segunda letra del nombre de Maxwell, que es A,
y asignaré un puntero a otro arreglo.
Y luego repetiré ese proceso para cada letra que tenga su nombre.
Así que el hash en la primera letra, la segunda letra, la tercera letra, cada vez
que lo hago me lleva a un nuevo arreglo.
Lo que no se muestra aquí es que cada una de estas matrices es de tamaño 26.
Sería atroz verlo en la pantalla.
Entonces usa demasiada memoria,
pero al final, hay un símbolo especial dibujado aquí, es un símbolo delta,
pero puede ser cualquier cosa, solo significa que Maxwell se detiene aquí.
Hay una palabra aquí.
Entonces, ¿cuántos pasos se necesitan para encontrar un nombre en el árbol?
Bueno, para encontrar a Maxwell es M-A-X-W-E-L-L. Entonces son siete pasos.
Para María sería M-A-R-I-A. Son cinco pasos.
Por lo tanto, sigue dependiendo de la cantidad de letras que haya en el nombre.
Pero si hay mil millones de nombres en este diccionario,
según esta definición, ¿cuántos pasos más se necesitan para encontrar a Maxwell?
M-A-X-W-E-L-L.
Y si hay mil millones de nombres en este diccionario,
¿cuánto tiempo tomaría encontrar a Maxwell?
M-A-X-W-E-L-L, es invariante.
Y si suponemos que ningún nombre humano será muy largo,
es efectivamente constante si son 10 caracteres, quizás 30 caracteres
o lo que sea.
Es efectivamente constante, significa que un trie da un tiempo constante de búsqueda
o la gran O de uno, lo que significa que es la estructura de datos más rápida.
Pero, por supuesto, se paga un precio con más memoria.
Sé que ya nos pasamos por un minuto, pero dejen los molesto con lo último.
Verán con Zamyla la implementación de esta estructura de datos.
Pero ahora comenzamos la transición, especialmente si están
algo preocupados porque estamos llegando a la mitad del semestre,
como diciendo, ¡oh, cielos!
Las cosas se vuelven cada vez más sofisticadas.
Y ahorita estamos en la cima de una colina, porque después de la serie de problemas
cinco haremos la transición a HTML, CSS, Python, JavaScript
y programación web en general.
Y la próxima semana veremos cómo funciona el Internet.
[REPRODUCCIÓN DE VIDEO]
[MÚSICA DE FONDO]
Llegó con un mensaje, con un protocolo propio.
[MÚSICA DE FONDO]
Llegó a un mundo de crueles firewalls, descifrando enrutadores y sorteando peligros
peores que la muerte.
Es rápido.
Es fuerte.
Es TCPIP
y tiene tu dirección.
GUERREROS DE LA RED.
[FINALIZA LA REPRODUCCIÓN]
DAVID: Muy bien.
Todo eso y más la próxima semana.
Nos vemos pronto.