Ejercicio 8.3. Recorridos en un tablero N ×N . Se dispone de un tablero N ×N cuyos casilleros están pintados de color blanco o negro (sin ningún criterio definido). Se debe dar todas las maneras (si hay) de llegar del casillero (1, 1) al casillero (N, N ) pisando sólo casilleros blancos. Desde un casillero (i, j) es posible moverse sólo en las direcciones N, S, E, O. No se permiten movimientos en diagonal. Utilice el método de backtracking para resolver el problema. Dado que no se dispone de niguna información a cerca de como estan distribuidos los cuadros negros en el tablero, la única manera de listar todas las soluciones es mediante la técnica de backtracking. Vamos a describir de manera precisa cual es el grafo cuyo recorrido nos proporciona las soluciones del problema. Por supuesto, este grafo no tiene existencia real en el programa que computa los caminos. Representamos al tablero como una matriz T de tamaño N ×N con entradas en el conjunto {B, N }, representando estos últimos a los casilleros blancos y negros. Desde el casillero p = (i, j) sólo es permitido ”avanzar” al casillero de arriba: (i − 1, j) si i > 1 , de la derecha: (i, j + 1) si j < N, de abajo: (i + 1, j) si i < N y de la izquierda: (i, j − 1) si j > 1. Utilizamos la notación p → q para indicar que q es uno de los movimientos permitidos desde p. El grafo en cuestión tiene como nodos a los casilleros blancos del tablero, y tenemos una arista de p hasta q si y sólo si p → q. Una solución será un camino p1 , ..., pk en este grafo tal que p1 = (1, 1), pk = (N, N ). Ejercicio: Dibuje el grafo correspondiente B N B B B B B N N B B B N N N B B B N B al tablero B B B N B El algoritmo caminos(T, c, p) imprime todas las soluciones cuyo tramo inicial es c, que es un camino cualquiera desde (1, 1) hasta p. Obtenemos todas las soluciones llamando a caminos(T, [(1, 1)], (1, 1)). Note que la precondición exige que T [1, 1] = B. Para evitar tener que buscar la existencia de un casillero q en la secuencia c, vamos a utilizar un marca ∗ en el tablero que nos indica que ese casillero ya fue pisado. proc caminos(in/out T : array[1..N, 1..N ] of {B, N, ∗}, in c : [par], in p : par) {c es un camino en el grafo asociado a T desde (1, 1) hasta p y para todo q, T [q] = ∗ si y sólo si q está en c} if p = (N, N ) then mostrar(c) 1 else for each q such that p → q do if T [q] = B then T [q] := ∗; caminos(T, c / q, q) else skip Utilizando la definición de → dada arriba podemos eliminar el for del programa. Utilizamos p.1 y p.2 para denotar la primera y segunda componente de p, resp. proc caminos(in/out T : array[1..N, 1..N ] of {B, N, ∗}, in c : [par], in p : par) {c es un camino en el grafo asociado a T desde (1, 1) hasta p y para todo q, T [q] = ∗ si y sólo si q está en c} if p = (N.N ) then mostrar(c) else if p.1 > 1 ∧ T [p.1 − 1, p.2] = B then T [p.1 − 1, p.2] := ∗; caminos(T, c / (p.1 − 1, p.2), (p.1 − 1, p.2)) if p.2 < N ∧ T [p.1, p.2 + 1] = B then T [p.1, p.2 + 1] := ∗; caminos(T, c / (p.1, p.2 + 1), (p.1, p.2 + 1)) if p.1 < N ∧ T [p.1 + 1, p.2] = B then T [p.1 + 1, p.2] := ∗; caminos(T, c / (p.1 + 1, p.2), (p.1 + 1, p.2)) if p.2 > 1 ∧ T [p.1, p.2 − 1] = B then T [p.1, p.2 − 1] := ∗; caminos(T, c / (p.1, p.2 − 1), (p.1, p.2 − 1)) Ejercicio 8.6. Listado de las hojas de un árbol binario. i. Desarrolle una algoritmo recursivo que devuelva la lista de hojas de un árbol binario T . Elimine luego la recursión de este algoritmo. ii. Lo mismo que en i pero ahora se deben listar las hojas con su profundidad. Por ejemplo para el árbol h hhh i , a, h ii , b, h ii , c , h h i , d, h ii i se debe devolver [(a, 2), (d, 1)]. i. La resolución de este problema es una simple modificación del listado de los nodos en preorden, cuyo versión iterativa es: Preorden de un árbol binario. |[U : T ree; ST : Stack[T ree]; empty stack(ST ); push(T, ST ); preorden := []; 2 while not is empty stack(ST ) do U := top(ST ); pop(ST ); if is null(U ) then skip; else preorden := preorden / root(U ); push(right(U ), ST ); push(lef t(U ), ST ); ]| (1) El algoritmo utiliza la pila ST de árboles para almacenar los subárboles del árbol original T que aún no se han listado. La secuencia preorden es una variable de acumulación que al finalizar el algoritmo alojará al preorden de T . Mientras el ciclo se ejecuta se mantiene la siguiente condición invariante: ”al concatenar preorden con los preordenes de los árboles alojados en ST , desde el tope hacia abajo, obtenemos el preorden de T ” Dicho de manera un poco más formal, si ST = [U1 , ..., Uk ] entonces P reorden(T ) = preorden + +P reorden(U1 ) + +...P reorden(Uk ). Es un buen ejercicio ejecutar el algoritmo para un input chico y corroborar que tal condición se mantiene al finalizar cada iteración. El algoritmo de recolección de hojas utiliza la pila de la misma manera que el preoden. La diferencia se establece en la linea (1), puesto que debemos discriminar para almacenar solamente las hojas. |[U : T ree; ST : Stack[T ree]; empty stack(ST ); push(T, ST ); hojas := []; while not is empty stack(ST ) do U := top(ST ); pop(ST ); if is null(U ) then skip; else if is null(lef t(U )) ∧ is null(right(U )) then hojas := hojas / root(U ); else push(right(U ), ST ); push(lef t(U ), ST ); ]| 3 (2) ii.Vamos a resolver ahora el problema de listar las hojas con su profundidad. Vamos a utilizar una pila para mantener las tareas pendientes, de manera similar a lo efectuado en i, sólo que ahora la pila nos debe brindar más información, puesto que en el momento de recolectar una hoja (linea (2) del programa anterior) debemos saber su profundidad. Luego recurrimos a una pila que contenga pares (n, U ) donde n es un natural que indica la profundidad del subárbol U en T . Cuando U tenga como raiz a una hoja de T , entonces n será la profundidad de tal hoja. La variable P alojaráa una par de este tipo y utilizamos P.1 y P.2 para denotar la primera y segunda componente de p, resp. ST : Stack[N at × T ree] |[U : T ree; P : N at × T ree; ST : Stack[N at × T ree]; empty stack(ST ); push( (0, T ) , ST ); hojas := []; while not is empty stack(ST ) do P := top(ST ); pop(ST ); if is null(P.2) then skip; else if is null(lef t(P.2)) ∧ is null(right(P.2)) then hojas := hojas / P ; else push( (P.1 + 1, right(U )) , ST ); push( (P.1 + 1, lef t(U )) , ST ); ]| Ejercicio 8.15. Orden topológico. a. Pruebe que un grafo dirigido acı́clico tiene siempre un vértice con valencia entrante 0. b. Sea G = (V, A) un grafo dirigido acı́clico, con |V | = n. Un orden topológico para G es un orden para sus vértices tal que si existe un camino desde v hasta w, entonces v aparece antes que w en ese orden. Pruebe por inducción constructiva que bajo las hipótesis dadas siempre existe un orden topológico. (El argumento inductivo debe arrojar el orden.) Ayuda: si v tiene valencia entrante 0, entonces claramente puede ocupar el primer lugar en el orden. c. Implemente de manera eficiente el algoritmo dado en b. Para esto deberá en cada paso disponerse de un vertice con valencia entrante 0. Para esto se llevará: i. Un array que dé para cada vértice su valencia entrante (Problema 61.a). Cuando se selecciona un vértice de valencia entrante 0, se le ”quita” del grafo y habrá que actualizar el arreglo de incidencias. ii. Una pila o cola que mantenga los vértices con valencia entrante 0 del grafo actual. 4 a. Supongamos que todo vértice tiene valencia ”in” mayor que 0, y sea v0 un vértice de G = (N, A). Entonces existe v1 ∈ N tal que (v1 , v0 ) ∈ A. Esta construcción se puede efectuar indefinidamente. En efecto, supongamos seleccionados v0 , ..., vk−1 ∈ A tal que (vi , vi−1 ) ∈ A para i = 1, ..., k − 1. Como la valencia ”in” de vk−1 es distinta de 0, entonces existe vk tale que (vk , vk−1 ) ∈ A. Como existe una cantidad finita de vértices, a partir de un determinado momento seleccionaremos vértices que ya han sido visitados. Sea v n el primer vértice que se repite, digamos vn = vm , con m < n. Entonces vn , vn−1 , ..., vm es un ciclo. Luego vale el contrarecı́proco: si G es aı́clico entonces existe un vértice con valencia ”in” igual a 0. b. Efectuaremos un argumento inductivo sobre el número de vértices n del grafo G = (N, A). El caso base n = 1 tiene respuesta trivial: el listado del único vértice es un orden topológico. Supongamos n > 1. Por el inciso (a) existe un vértice v0 con valencia ”in” igual a 0. Sea G0 = (N − {v0 }, A0 ), donde A0 es el subconjunto de A formado por las aristas que no tienen a v0 como origen ni como destino. Claramente G0 es un grafo dirigido acı́clico con n − 1 vértices. Luego, por hipótesis inductiva, existe un orden topológico v1 , ..., vn−1 para los vértices de G0 . Veamos que v0 , v1 , ..., vn−1 es un orden topológico para G. En efecto, sea v, w vértices distintos tales que se puede llegar a w desde v por un camino c. Veamos que v antecede a w en la secuencia v0 , v1 , ..., vn−1 . Claramente v0 6= w, ya que v0 tiene valencia entrante 0. Si v0 = v entonces la afirmación es trivial. Supongamos entonces que v 6= v0 6= w. Ya que v0 posee valencia entrante 0, tenemos que ningún camino de v a w pasa por v0 . Luego c es necesarimente un camino en G0 . Como v1 , ..., vn−1 es un orden topológico para los vértices de G0 , tenemos que v antecede a w en la secuencia v1 , ..., vn−1 . c. Vamos ahora a ocuparnos de algunos aspectos importantes de la implementación, principalmente los referidos a la obtención de un vértice de valencia 0. No nos referiremos a los detalles relativos a la implementación del grafo G (input del algoritmo) ni de la lista s en la que se muestra al resultato. De esta manera el programa podrá ser adaptado para distintas implementaciones. NOTA: En el algoritmo utilizaremos la notación (1) for each w such that (v, w) ∈ A do ... Usualmente el grafo viene dado en una lista o arreglo en la que se dipone para cada vértice la lista de sus adyacentes. Por ejemplo G podrı́a ser un arreglo que en el lugar G[v] aloja la lista de todos los w tales que (v, w) ∈ A. En este caso, (1) deberá ser reemplazado por un recorrido por la lista G[v]. Otra posibilidad es que G esté dado mediante una matriz de booleanos, de manera que G[v, w] = true si y sólo si (v, w) ∈ N . En tal caso para implementar (1) recorreremos la fila v de la matriz. 5 Consideraciones similares valen para la variable s en la que se muestra el orden topológico. Utilizaremos por ejemplo la instrucción insert v at the end of s La variable s podrı́a ser por ejemplo un cola, o un arreglo con un puntero al final. FIN NOTA. El algoritmo desarrollado en b consiste básicamente en extraer en cada paso un vértice v de valencia ”in” 0 del grafo. El vértice v se agrega a la lista como último elemento, y el grafo se ”achica” quitando el vértice v y todas sus aristas. Denotaremos mediante G − {v} a este grafo. Dado que en cada paso sólo necesitamos conocer un vértice de valencia 0 del grafo ”actual” G, no es necesario mantener a G como variable de estado. Basta con mantener un cola que contenga a los vértices con esta propiedad. Por supuesto, se debe resolver el siguiente problema: si C es la cola que contiene a los vértices de G con valencia ”in” 0, y a G se le extrae el vértice v, ¿como debe actualizarse C de manera de contener a los vértices con valencia ”in” 0 de G−{v}? Es claro que si w (distinto de v) tiene valencia 0 en G, entonces tendrá también valencia 0 en G−{v}. Pero puede ocurrir que un vértice w con valencia 1 de G pertenezca a una arista de la forma (w, v). Luego w tendrá valencia ”in” 0 en G − {v}. Luego se infiere que es necesario contar con la información de las valéncias ”in” de todos los vértices, no sólo los de valencia 0. La forma indicada de resolver el problema es llevar un arreglo de valencias ”in” V al, tal que V al[v] aloje la valencia ”in” del nodo v. En el problema anterior se ha programado la inicialización de V al. El problema de la actualización de V al y C en cada paso se resuelve de la siguiente manera. Cuando se extrae el vértice v se debe restar 1 a V al[w], para todo vértice w tal que (v, w) ∈ A. Además se debe luego recorrer V al para verificar si hay un nuevo vértice con valencia ”in” 0, el cual se debe incorporar a C. La función tendrá la especificación: func topological sorting(G0 : graf o) dev s : list of items {pre G0 acı́clico ∧ N = {1, ..., n}} {pos s es un orden topológico para G0 } Como mencionamos antes no es necesario llevar una variable de estado G que represente al grafo actual. Levaremos las variables locales: V al : array [1, n] of nat; C : queue of items; Mediante V al[v] = −1 indicamos que v ya no pertenece al grafo actual G. De esta manera G queda definido a través de los vértices w tales que V al[w] ≥ 0. Podemos citar entonces las condiciones que se mantendrán invariantes al finalizar cada paso, y que constituirán el invarinte del loop principal: 6 1. s con 2. 3. Un sorting topologico para el grafo original G0 se obtiene concatenando un sorting topológico para el grafo actual G. Si w es un vértice de G entonces V al[w] es su valencia ”in” en G. C contiene al conjunto de ls vértices w de G tales que V al[w] = 0. Inicialización del ciclo: La inicialización de V al está dada por el algoritmo array de valencias in, desarrollado en el problema anterior. La inicialización de C consiste en recorrer V al recolectando los vértices w tales que V al[w] = 0. Guarda y final del ciclo: Según la condición invariante 1, obtendremos un orden topológico para G0 cuando G sea el grafo sin nodos, que es equivalente a decir que C es vacı́a (por qué?). Por lo tanto la guarda es ¬is empty(C). El programa cmpleto es: func topological sorting(G0 : graf o) dev s : list of items {G0 acı́clico ∧ G = G0 ∧ N = {1, ..., n}} |[V al : array [1, n] of nat; C : queue of items; v : nat; array de valencias in(G, V al); C := empty; v := 0; while v 6= n do if V al[v] = 0 then insert(v, C); v := v + 1; s := []; while ¬is empty(C) do v := f irst(C); dequeue(C); V al[v] := −1; insert v at the end of s; for each w such that (v, w) ∈ A do V al[w] := V al[w] − 1; if V al[w] = 0 then insert(w, C); end end ]| {pos s es un orden topológico para G0 } 7