jueves, 26 de octubre de 2023

Algoritmo de Bellman-Ford en Python

Algoritmo de Bellman-Ford en Python

En la entrada anterior vimos la implementación del algoritmo de Dijkstra para calcular distancias cortas entre los nodos de un grafo. En esta ocasión vamos a revisar el algoritmo Bellman-Ford.

El algoritmo de Bellman-Ford es un algoritmo de búsqueda de caminos más cortos en un grafo que puede manejar aristas con pesos negativos, a diferencia del algoritmo de Dijkstra. Sin embargo, el algoritmo de Bellman-Ford es menos eficiente que el algoritmo de Dijkstra y tiene una complejidad de tiempo de O(V ⋅ E), donde V es el número de nodos y E es el número de aristas en el grafo.

Pseudocódigo del algoritmo

  1. Inicializar las distancias a todos los nodos como infinito, excepto el nodo de origen que se inicializa como 0.
  2. Para cada nodo en el grafo:
    • Para cada arista (u, v) en el grafo:
      • Si distancia[u] + peso(u, v) < distancia[v], actualizar distancia[v] = distancia[u] + peso(u, v).
  3. Repetir el paso 2 para un total de V-1 veces (V es el número de nodos en el grafo).
  4. Verificar si hay ciclos de peso negativo:
    • Para cada arista (u, v) en el grafo:
      • Si distancia[u] + peso(u, v) < distancia[v], entonces hay un ciclo de peso negativo en el grafo.
  5. Las distancias finales representan los caminos más cortos desde el nodo de origen a todos los demás nodos en el grafo.

Definición del código del algoritmo

De manera similar que el algoritmo de Dijkstra, para este ejemplo vamos a trabajar con clases de Python, así que lo primero que haremos será definir los métodos de la clase (Graph) para luego presentar el código completo de la misma.

Método __init__

Cómo vimos anteriormente, cuando se trabaja con clases en Python, es común definir el método __init__(self) para inicializar la instancia del objeto con valores que se requieran. De igual manera, en este caso vamos a inicializar la instancia con dos atributos, un conjunto para almacenar los nodos y un conjunto para almacenar las aristas.

def __init__(self):
    self.nodes = set()
    self.edges = set()

Método para agregar nodos

Este método recibe como parámetro el valor del nodo que se quiere agregar y lo inserta en el atributo nodes que definimos en el método __init__.

Nota: La idea de que los nodos sean un objeto del tipo set() es porque deben ser únicos y no deben existir nodos duplicados.

def addNode(self, value):
    self.nodes.add(value)

Método para agregar aristas

El método para agregar aristas a más del parámetro self recibe tres parámetros, origin para referirse al nodo de origen, destiny correspondiente al nodo de destino y weight que hace referencia al peso de la arista desde el origen hasta el destino. Con estos parámetros se crea una tupla que se va a agregar en el atributo edges de tipo set (conjunto) de la clase.

def addEdge(self, origin, destiny, weight):
    self.edges.add((origin, destiny, weight))

Método del algoritmo de Bellman-Ford

Para este método se requiere como parámetro el nodo de inicio desde donde se va a calcular las distancias. Con este valor ejecutamos los pasos definidos en el pseudocódigo de la siguiente manera:

def bellman_ford(self, inicio):
        # Paso 1: Inicializamos las distancias
        distancias = {nodo: float('infinity') for nodo in self.nodos}
        distancias[inicio] = 0

        # Paso 2: Ejecutamos la relajación de aristas V-1 veces
        for _ in range(len(self.nodos) - 1):
            for u, v, peso in self.aristas:
                if distancias[u] + peso < distancias[v]:
                    distancias[v] = distancias[u] + peso

        # Paso 4: Verificamos los ciclos de peso negativo
        for u, v, peso in self.aristas:
            if distancias[u] + peso < distancias[v]:
                print("El grafo contiene un ciclo de peso negativo")
                return

        # Las distancias finales representan los caminos más cortos desde el nodo de origen
        return distancias

Clase Graph

Con todos los métodos de la clase Graph definidos, vamos a ver cómo quedaría todo junto:

class Graph:
    def __init__(self):
        self.nodes = set()
        self.edges = set()

    def addNode(self, value):
        self.nodes.add(value)

    def addEdge(self, origin, destiny, weight):
        self.edges.add((origin, destiny, weight))

    def bellman_ford(self, start):
        # Paso 1: Inicializar las distancias
        distances = {node: float('infinity') for node in self.nodes}
        distances[start] = 0

        # Paso 2: Relajación de aristas V-1 veces
        for _ in range(len(self.nodes) - 1):
            for u, v, weight in self.edges:
                if distances[u] + weight < distances[v]:
                    distances[v] = distances[u] + weight

        # Paso 4: Verificar ciclos de peso negativo
        for u, v, weight in self.edges:
            if distances[u] + weight < distances[v]:
                print("The graph contains a negative weight cycle")
                return

        # Las distancias finales representan los caminos más cortos desde el nodo de origen
        return distances

Probando el funcionamiento

Una vez definida la lógica de la clase Graph, podemos realizar pruebas para validar que la implementación funciona de manera correcta. Para esto, igual que antes lo primero que debemos hacer es crear una instancia de la clase:

graph = Graph()

Con esta instancia creamos los nodos de nuestro grafo de la misma manera que antes, es decir 8 nodos correspondientes a las letras de la A hasta la H.

graph.addNode('A')
graph.addNode('B')
graph.addNode('C')
graph.addNode('D')
graph.addNode('E')
graph.addNode('F')
graph.addNode('G')
graph.addNode('H')

Así mismo creamos las aristas con los mismos valores que en el ejemplo anterior para tener el mismo grafo:

graph.addEdge('A', 'B', 4)
graph.addEdge('A', 'C', 9)
graph.addEdge('A', 'E', 7)
graph.addEdge('A', 'F', 3)
graph.addEdge('A', 'G', 11)
graph.addEdge('A', 'H', 15)
graph.addEdge('B', 'C', 4)
graph.addEdge('B', 'D', 4)
graph.addEdge('B', 'E', 2)
graph.addEdge('B', 'G', 9)
graph.addEdge('C', 'D', 8)
graph.addEdge('C', 'F', 2)
graph.addEdge('D', 'G', 11)
graph.addEdge('D', 'F', 8)
graph.addEdge('D', 'E', 5)
graph.addEdge('D', 'H', 4)
graph.addEdge('E', 'G', 3)
graph.addEdge('E', 'H', 14)

El grafo creado tiene la siguiente estructura:

Ahora debemos definir cuál va a ser nuestro punto de partida y con eso calcular las distancias:

start = 'A'
distances = graph.bellman_ford(start)

Por último vamos a mostrar cuáles son los caminos más cortos desde el nodo de partida A hacia el resto de los nodos, para eso recorremos todos los elementos de las distancias e imprimimos el resultado:

print("Distancias más cortas desde el nodo", start)
for node, distance in distances.items():
    print(f"A -> {node}: {distance}")

Y obtenemos el siguiente resultado, que como podemos apreciar nos muestra las mismas distancias que el algoritmo de Dijkstra:

Distancias más cortas desde el nodo A
A -> F: 3
A -> E: 6
A -> G: 9
A -> C: 8
A -> A: 0
A -> D: 8
A -> H: 12
A -> B: 4

miércoles, 25 de octubre de 2023

Algoritmo de Dijkstra en Python

Algoritmo de Dijkstra en Python

El algoritmo de Dijkstra es un algoritmo clásico en teoría de grafos utilizado para encontrar el camino más corto desde un nodo de origen a todos los demás nodos en un grafo ponderado (con pesos en las aristas). La lógica del algoritmo de Dijkstra se presenta a continuación:

Definiciones Previas:

  • Grafo Ponderado: Es aquel grafo en el que cada arista tiene un peso (o costo) asociado.
  • Distancia: Corresponde a la longitud del camino más corto entre dos nodos.
  • Nodo de Origen: El nodo desde el cual se inicia la búsqueda del camino más corto.

Pasos del algoritmo

  1. Inicialización:
    - Crea un conjunto de nodos no visitados y asigna una distancia inicial de infinito a todos los nodos excepto al nodo de origen, al que se le asigna una distancia de 0.
    • Crea un conjunto vacío de nodos visitados.
  2. Bucle Principal:
    - Mientras haya nodos no visitados:
    - Encuentra el nodo no visitado con la distancia mínima del conjunto de nodos no visitados. Llamémoslo nodo_actual.
    - Marca nodo_actual como visitado y lo elimina del conjunto de nodos no visitados.
    • Para cada vecino vecino del nodo_actual que aún no ha sido visitado:
      • Calcula la nueva distancia desde el nodo de origen hasta vecino pasando por nodo_actual.
      • Si esta nueva distancia es menor que la distancia almacenada para vecino, actualiza la distancia almacenada para vecino.
  3. Fin del Algoritmo:
  • Una vez que todos los nodos hayan sido visitados o el nodo de destino haya sido visitado, el algoritmo termina. Las distancias calculadas representan los caminos más cortos desde el nodo de origen a cada nodo en el grafo.

Definición del código del algoritmo

Una vez vista la lógica del algoritmo, vamos a proceder a la creación del código del mismo. En esta ocasión vamos a trabajar con clases en Python para definir la clase Graph que nos permitirá definir la lógica del algoritmo. Esta clase Graph va a contener los métodos para agregar nodos, agregar aristas y ejecutar el algoritmo de Dijkstra para determinar los caminos más cortos.

Vamos a ver cómo definimos los métodos de la clase Graph para luego verlo todo junto:

Método __init__

Cuando se trabaja con clases en Python, es común definir el método __init__(self) para inicializar la instancia del objeto con valores que se requieran. En este caso vamos a inicializar la instancia con dos atributos, un conjunto para almacenar los nodos y un diccionario para almacenar las aristas.

Nota Cuando se trabaja con clases, es común utilizar la palabra reservada self en la definición de los métodos para recibirla como argumento, ya que esta declaración hace referencia a la clase misma y a través de self puede acceder a todos los componentes de la clase.

def __init__(self):
    self.nodes = set()
    self.edges = {}

Método para agregar nodos

Este método recibe como parámetro el valor del nodo que se quiere agregar y lo inserta en el atributo nodes que definimos en el método __init__. Adicional a esto agrega una entrada en el diccionario de aristas en donde la clave corresponde al valor del nodo y el valor se asigna como una lista vacía, que se usará luego para agregar los nodos a los que está conectado (es decir las aristas).

Nota: La idea de que los nodos sean un objeto del tipo set() es porque deben ser únicos y no deben existir nodos duplicados.

def addNode(self, value):
    self.nodes.add(value)
    self.edges[value] = []

Método para agregar aristas

El método para agregar aristas a más del parámetro self recibe tres parámetros, origin para referirse al nodo de origen, destiny correspondiente al nodo de destino y weight que hace referencia al peso de la arista desde el origen hasta el destino. Esta relación se ingresa en los dos sentidos

def addEdge(self, origin, destiny, weight):
    self.edges[origin].append((destiny, weight))

Método del algoritmo de Dijkstra

Para definir este método se debe recalcar que se utiliza la biblioteca heapq de Python que consiste en un módulo que proporciona implementaciones de colas de prioridad utilizando estructuras de datos de montículos binarios (heaps). Los montículos binarios son estructuras de datos que mantienen una propiedad especial: el elemento en la posición i es siempre menor o igual que los elementos en las posiciones 2i+1 y 2i+2. Esto permite implementar fácilmente colas de prioridad.

Nota: Si se utiliza una cola de prioridad (por ejemplo, un montículo binario) para la implementación del algoritmo de Dijkstra, el tiempo de ejecución es O((V + E) log V), donde V es el número de nodos y E es el número de aristas en el grafo.

    def dijkstra(self, start):
        # Realizamos la inicialización definiendo una distancia de valor infinito
        # a cada uno de los nodos no visitados
        distances = {node: float('infinity') for node in self.nodes}
        # Adignamos la distancia de 0 la nodo de origen
        distances[start] = 0
        # Creamos un conjunto de nodos visitados
        tempQueue = [(0, start)]
        # Iniciamos el bucle principal
        while tempQueue:
            # Obtenemos el nodo con la distancia más baja de la cola
            actualDistance, actualNode = heapq.heappop(tempQueue)
            # Si encontramos una distancia más larga a un nodo, omitimos este nodo
            if actualDistance > distances[actualNode]:
                continue
            for neighbor, weight in self.edges[actualNode]:
                # Calculamos la distancia tentativa al vecino
                distance = actualDistance + weight
                if distance < distances[neighbor]:
                    # Actualizamos la distancia si encontramos un camino más corto
                    distances[neighbor] = distance
                    # Agregamos el vecino a la cola de prioridad
                    heapq.heappush(tempQueue, (distance, neighbor))
        # Devuelve las distancias más cortas desde el nodo de inicio a todos los demás nodos del grafo
        return distances

Clase Graph

Una vez definidos todos los métodos de la clase Graph, vamos a ver cómo quedaría todo junto:

import heapq

class Graph:
    def __init__(self):
        self.nodes = set()
        self.edges = {}

    def addNode(self, value):
        self.nodes.add(value)
        self.edges[value] = []

    def addEdge(self, origin, destiny, weight):
        self.edges[origin].append((destiny, weight))

    def dijkstra(self, start):
        # Realizamos la inicialización definiendo una distancia de valor infinito
        # a cada uno de los nodos no visitados
        distances = {node: float('infinity') for node in self.nodes}
        # Adignamos la distancia de 0 la nodo de origen
        distances[start] = 0
        # Creamos un conjunto de nodos visitados
        tempQueue = [(0, start)]
        # Iniciamos el bucle principal
        while tempQueue:
            # Obtenemos el nodo con la distancia más baja de la cola
            actualDistance, actualNode = heapq.heappop(tempQueue)
            # Si encontramos una distancia más larga a un nodo, omitimos este nodo
            if actualDistance > distances[actualNode]:
                continue
            for neighbor, weight in self.edges[actualNode]:
                # Calculamos la distancia tentativa al vecino
                distance = actualDistance + weight
                if distance < distances[neighbor]:
                    # Actualizamos la distancia si encontramos un camino más corto
                    distances[neighbor] = distance
                    # Agregamos el vecino a la cola de prioridad
                    heapq.heappush(tempQueue, (distance, neighbor))
        # Devuelve las distancias más cortas desde el nodo de inicio a todos los demás nodos del grafo
        return distances

Probando el funcionamiento

Una vez definida la lógica de la clase Graph, podemos realizar pruebas para validar que la implementación funciona de manera correcta. Para esto lo primero que debemos hacer es crear una instancia de la clase:

graph = Graph()

Ahora vamos a crear los nodos de nuestro grafo, para este caso vamos a crear 8 nodos correspondientes a las letras de la A hasta la H.

graph.addNode('A')
graph.addNode('B')
graph.addNode('C')
graph.addNode('D')
graph.addNode('E')
graph.addNode('F')
graph.addNode('G')
graph.addNode('H')

Con los nodos creados podemos definir las aristas con sus respectivos pesos:

graph.addEdge('A', 'B', 4)
graph.addEdge('A', 'C', 9)
graph.addEdge('A', 'E', 7)
graph.addEdge('A', 'F', 3)
graph.addEdge('A', 'G', 11)
graph.addEdge('A', 'H', 15)
graph.addEdge('B', 'C', 4)
graph.addEdge('B', 'D', 4)
graph.addEdge('B', 'E', 2)
graph.addEdge('B', 'G', 9)
graph.addEdge('C', 'D', 8)
graph.addEdge('C', 'F', 2)
graph.addEdge('D', 'G', 11)
graph.addEdge('D', 'F', 8)
graph.addEdge('D', 'E', 5)
graph.addEdge('D', 'H', 4)
graph.addEdge('E', 'G', 3)
graph.addEdge('E', 'H', 14)

Con esto tenemos nuestro grafo listo, y se encuentra configurado de la siguiente manera:

Ahora debemos definir cuál va a ser nuestro punto de partida y con eso calcular las distancias:

inicio = 'A'
distancias = graph.dijkstra(inicio)

Por último vamos a mostrar cuáles son los caminos más cortos desde el nodo de partida A hacia el resto de los nodos, para eso recorremos todos los elementos de las distancias e imprimimos el resultado:

print("Distancias más cortas desde el nodo", inicio)
for nodo, distancia in distancias.items():
    print(f"A -> {nodo}: {distancia}")

Y obtenemos el siguiente resultado:

Distancias más cortas desde el nodo A
A -> C: 8
A -> G: 9
A -> B: 4
A -> E: 6
A -> D: 8
A -> F: 3
A -> H: 12
A -> A: 0

lunes, 23 de octubre de 2023

Validador de contraseñas seguras con Python

Validador de contraseñas seguras con Python

Cuando se crean cuentas de usuario, es común que al momento de registrar una contraseña esta deba cumplir con varios parámetros de seguridad, como por ejemplo la longitud o contener caracteres especiales.

En esta ocasión vamos a ver un ejemplo de cómo realizar un validador de contraseñas en Python, para comprobar que cumplen con los siguientes parámetros específicos de seguridad:

  • Contener al menos una letra minúscula
  • Contener al menos una letra mayúscula
  • Contener al menos un número
  • Contener al menos un caracter especial
  • Tener una longitud mínima de 6 caracteres
  • Tener una longitud máxima de 25 caracteres

Para realizar varias estas validaciones vamos a utilizar expresiones regulares, por lo que deberemos importar este módulo. Adicional para imprimir por consola el mensaje de validación, vamos a usar el módulo pprint de Python.

Importar los módulos requeridos

Los módulos que vamos a importar son los siguientes:

from pprint import pprint
from re import match

Definición de constantes

Como se ha indicado, se va a validar que la longitud de la contraseña esté en un rango de 6 a 25 caracteres, por lo que vamos a definir estos valores como una constante, para luego utilizarlos en las validaciones.

MIN_LENGTH = 6
MAX_LENGTH = 25

Funciones de validación de reglas

Para hacerlo más visual, vamos a definir una función para validar cada uno de los parámetros que debe cumplir una contraseña para considerarla válida.

Función de validación de letra mayúscula

Como se mencionó antes, varias de las validaciones se van a realizar mediante expresiones regulares. Para esta validación se va a usar la expresión regular que compruebe si existe al menos una letra mayúscula dentro de la cadena de texto recibida como contraseña.

def validateUpper(text):
    return bool(match(r'\w*[A-Z]\w*', text))

Como se puede apreciar, se utiliza la función match que importamos al inicio, en esta se proporciona la expresión regular r’\w*[A-Z]\w*’ encargada de buscar alguna coincidencia de las letras mayúsculas en el texto (text) recibido. Al resultado de esta validación se le hace un cast con la función bool() que nos devolverá True si se encuentra alguna letra mayúscula o False si no existe.

Función de validación de letra minúscula

De manera similar, para validar la existencia de letras minúsculas dentro de la cadena de texto, usamos una expresión regular.

def validateLower(text):
    return bool(match(r'\w*[a-z]\w*', text))
Función de validación de números

Así mismo, vamos a realizar la validación de existencia de números en la cadena de texto.

def validateNumber(text):
    return bool(match(r'\w*[0-9]\w*', text))
Función de validación de caracteres especiales

Para comprobar la existencia de caracteres especiales, vamos a buscar dentro de la cadena de texto algún caracter que no sea alfanumérico. Es decir que al recorrer todos los caracteres de la cadena de texto, si alguno es diferente de una letra o un número, es porque debe ser un caracter especial. Para comprobar eso, hacemos uno de la función any(), la cual recibe un objeto iterable y verifica si existe al menos un elemento dentro del objeto iterable es True para devolver True como resultado, caso contrario retorna False indicando que el objeto iterable está vació o que no existe ningún elemento que sea True.

El objeto iterable lo obtenemos mediante la creación de una lista de valores True y False mediante el uso del método isalnum() que verifica si un caracter es alfanumérico.

def validateSpecialChar(text):
    return any(not c.isalnum() for c in text)
Función de validación de la longitud mínima

Para comprobar si la cadena recibida cumple con la longitud mínima requerida, usamos la función len() para obtener la longitud de la cadena y compararlo con el valor MIN_LENGTH que definimos al inicio.

def validateMinLength(text):
    return len(text) >= MIN_LENGTH
Función de validación de la longitud máxima

De manera similar, usamos la función len() para obtener la longitud de la cadena y compararlo con el valor MAX_LENGTH que definimos al inicio.

def validateMinLength(text):
    return len(text) <= MAX_LENGTH

Una vez definidas estas funciones, podemos crear una función adicional en la cual integraremos todas las funciones de validación para crear nuestro validador.

El validador consistirá en un diccionario, en el que se agregará el listado de las validaciones, cada una de las cuales tendrá los siguientes campos:

  • name: para definir el nombre de la validación a la que se hace referencia
  • value: para asignar un valor booleano, True si cumple con el requerimiento o False si no lo cumple
  • message: para indicar un mensaje de aprobación si se cumple el requerimiento o de error si no se cumple.

En el diccionario también se agrega una validación general que indica si la contraseña ingresada es correcta (cumple con todos los parámetros) o no, asignando el valor de True o False respectivamente. Para esto se usa la función all() de Python la cual se encarga de validar que todos los elementos en un objeto iterable son True para retornar un valor de True, caso contrario, si al menos un elemento del objeto iterable es diferente de True retorna False.

La definición de la función es la siguiente:

def validatePassword(text):
    validation = {}
    validations = [
        {
            "name": "uppercase",
            "value": validateUpper(text),
            "message": "Password contains at least one uppercase character" if validateUpper(
                text) else "Must contain at least one capital letter"
  },
        {
            "name": "lowercase",
            "value": validateLower(text),
            "message": "Password contains at least one lowercase character" if validateLower(
                text) else "Must contain at least one lowercase letter"
  },
        {
            "name": "number",
            "value": validateNumber(text),
            "message": "Password contains at least one number" if validateNumber(
                text) else "Must contain at least one number"
  },
        {
            "name": "specialChar",
            "value": validateSpecialChar(text),
            "message": "Password contains at least one special character" if validateSpecialChar(
                text) else "Must contain at least one special character"
  },
        {
            "name": "minLength",
            "value": validateMinLength(text),
            "message": "Password meets the minimum required length" if validateMinLength(
                text) else f"Password length must be greater than {MIN_LENGTH}"
  },
        {
            "name": "maxLength",
            "value": validateMaxLength(text),
            "message": "Password meets the maximum required length" if validateMaxLength(
                text) else f"Password length must be lower than {MAX_LENGTH}"
  }]
    validation["isValid"] = all([k.get("value") for k in validations])
    validation["validations"] = validations
    pprint(validation)

Para compobar el funcionamiento de nuestro validador, vamos a ejecutarlo con una cadena de texto de prueba que sí cumpla los parámetros y otra que no los cumpla.

Prueba con cadena que cumple los parámetros

Se va a usar la cadena de ejemplo:

V3ryS3cur3Pa$$word

El resultado de validar esta cadena de texto será el siguiente:

{
  'isValid': True,
  'validations': [
    {
      'message': 'Password contains at least one uppercase'
  'character',
      'name': 'uppercase',
      'value': True
    },
    {
      'message': 'Password contains at least one lowercase'
  'character',
      'name': 'lowercase',
      'value': True
    },
    {
      'message': 'Password contains at least one number',
      'name': 'number',
      'value': True
    },
    {
      'message': 'Password contains at least one special character',
      'name': 'specialChar',
      'value': True
    },
    {
      'message': 'Password meets the minimum required length',
      'name': 'minLength',
      'value': True
    },
    {
      'message': 'Password meets the maximum required length',
      'name': 'maxLength',
      'value': True
    }
  ]
}

Como podemos observar, se cumplen todos los parámetros, por lo tanto, la validación de esta contraseña es correcta.

Prueba con cadena que no cumple los parámetros

Para comprobar que las validaciones se realizan de manera correcta, ahora probaremos una cadena de texto más simple y observaremos el resultado. La cadena a usar es la siguiente:

12345

Y el resultado obtenido es este:

{
  'isValid': False,
  'validations': [
    {
      'message': 'Must contain at least one capital letter',
      'name': 'uppercase',
      'value': False
    },
    {
      'message': 'Must contain at least one lowercase letter',
      'name': 'lowercase',
      'value': False
    },
    {
      'message': 'Password contains at least one number',
      'name': 'number',
      'value': True
    },
    {
      'message': 'Must contain at least one special character',
      'name': 'specialChar',
      'value': False
    },
    {
      'message': 'Password length must be greater than 6',
      'name': 'minLength',
      'value': False
    },
    {
      'message': 'Password meets the maximum required length',
      'name': 'maxLength',
      'value': True
    }
  ]
}

En este caso vemos que la contraseña ingresada no es válida, esto debido a que los únicos parámetros que cumple (True) son el de contener al menos un número y el de ser de longitud menor a 25 caracteres.