¿Qué es un problema algorítmico que es fácil de resolver en Haskell pero difícil de resolver en Python?

Es difícil encontrar pequeños ejemplos que sean inequívocamente mejores en Haskell. Incluso si tenemos un programa más limpio y corto en Haskell, las personas que no estén familiarizadas con sus abstracciones afirmarán que la versión de Python es “obvia” y que la versión de Haskell es demasiado esotérica. Y oye, supongo que eso es cierto para ellos, solo dice más sobre lo que están familiarizados que la calidad del código en cualquier idioma.

Siento que esto descalifica a la mayoría de las frases de Haskell: demasiadas personas las verían como simplistas y poco más. Considere la función “list powerset” citada con frecuencia que le ofrece todas las sublistas del argumento:

powerset :: [a] -> [[a]]
powerset = filterM (const [True, False])

Personalmente, estoy completamente cómodo con este código. Define qué es un conjunto de potencia: es lo que obtienes si ambos incluyes y excluyes todos los elementos posibles en una lista. Pero ya me siento cómodo pensando de esta manera; alguien que no esté, tal vez alguien que no esté familiarizado con el funcionamiento de la mónada de la lista o alguien con una mentalidad más operativa, encontraría esta función ofuscada. (De hecho, ese es exactamente el segundo comentario dicho en el hilo de Reddit que encontré buscando en Google “Haskell powerset”).

El código es demasiado lindo.

Un buen ejemplo tendría que ser un poco más grande y depender de la expresividad de Haskell de formas que serían incómodas de reproducir en Python. Entonces, eso es lo que tenemos que identificar primero: de qué manera es Haskell más expresivo, ya sea como lenguaje o mediante bibliotecas, de lo que vería en Python.

Tengo un par en mente, pero podríamos llegar a más.

  • Los tipos de datos algebraicos , junto con la coincidencia de patrones, facilitan el trabajo con toda una clase de tareas. En particular, es mucho más fácil escribir código que funcione con lenguajes de programación o lenguajes formales.
  • La pereza es excepcionalmente poderosa. Entre otras cosas, facilita la representación de datos de tamaño y resolución arbitrarios sin perder información cuando las cosas se combinan, “en la costura”. Un ejemplo un poco complicado es un tipo de datos para datos de imágenes en 2D con una extensión y resolución infinitas , que podemos obtener con cuadrantes perezosos.

El segundo es especialmente relevante: me imagino haciéndolo en Haskell, incluso razonablemente fácilmente en el caso más simple, y no puedo imaginarme hacerlo en Python. En el mejor de los casos, trataría de transferir mi código Haskell, pero sería increíblemente tedioso y propenso a errores.

Tipos de datos algebraicos

En Haskell, es natural expresar sus cálculos mediante la coincidencia de patrones sobre tipos de datos algebraicos. A menudo, el código que tiene que escribir fluye directamente de los tipos: configura su problema y la solución queda directamente fuera de la configuración.

De hecho, esto apareció en el mundo real, en mi entrevista con Jane Street. Por supuesto, no puedo decirte los detalles de la pregunta, pero me fue mucho más fácil de lo que debería. Fue significativamente más fácil que las otras preguntas que tenía y creo que lo hice mucho mejor que la mayoría de las personas, todo porque usé Haskell. (Bueno, para ser justos, OCaml habría sido igual de bueno en ese caso).

¿Y por qué? Porque se centró en trabajar con lenguajes formales y, a través de eso, árboles. Después de definir los tipos que necesitaba, la resolución del problema fue casi sistemática: el patrón coincidía con mis tipos, manejaba todos los casos y derivaba la función final paso a paso.

Esto será válido para la mayoría de los problemas relacionados con lenguajes de programación o lenguajes formales, especialmente problemas lo suficientemente pequeños como para caber en una entrevista. Un ejemplo podría ser implementar un solucionador algebraico que pueda manejar variables y simplifique las expresiones aritméticas tanto como sea posible. Empiezas con los tipos

data Expr = Var String
| Lit Integer
| Expr :+: Expr
| Expr :-: Expr
| Expr :*: Expr deriving (Show, Eq)

Luego implementas la lógica para simplificar. Es un poco complicado, pero fluye directamente de los tipos, lo suficientemente simple en Haskell pero, creo, bastante más difícil en Python.

simplify :: Expr -> Expr
simplify expr
| nextStep == expr = expr
| otherwise = simplify nextStep
where nextStep = case expr of
Var x -> Var x
Lit n -> Lit n

(Encendido n1: +: Encendido n2) -> Encendido (n1 + n2)
(Encendido n1: -: Encendido n2) -> Encendido (n1 – n2)
(Encendido n1: *: Encendido n2) -> Encendido (n1 * n2)

(e1: +: e2) -> simplificar e1: +: simplificar e2
(e1: -: e2) -> simplificar e1: -: simplificar e2
(e1: *: e2) -> simplificar e1: *: simplificar e2

Una parte realmente genial es que este código es fácil de extender. Por ejemplo, para manejar 0 y 1 inteligente, solo tendría que agregar algunos patrones más:

(Lit 0 :*: _) -> Lit 0
(_ :*: Lit 0) -> Lit 0

(Iluminado 0: +: e) -> simplificar e
(e: +: Lit 0) -> simplificar e
(Lit 1: *: e) -> simplificar e
(e: *: Lit 1) -> simplificar e

La forma del código sigue de cerca los tipos y las reglas de simplificación se expresan de forma natural inmediata. El código es fácil de derivar y los tipos lo ayudan a cubrir todos los casos que necesita y hacen que sea más fácil extender el código con una funcionalidad adicional, algo que los entrevistadores adoran preguntar. (Con buena razón: ¡es algo que es increíblemente importante en la programación del mundo real también!)

Podría hacer todo esto en Python, por supuesto, pero sería más incómodo y doloroso. He experimentado esto de primera mano cuando la tarea para mi clase de lenguaje de programación estaba en Python. La historia de Haskell es mucho más agradable.

pereza

Otra faceta de Haskell que se destaca como particularmente expresiva es la pereza . Confiando en la pereza, podemos expresar ideas y abstracciones directamente que serían dolorosas sin ella.

Una ventaja es que podemos escribir algoritmos que deben evaluarse intercalados en fragmentos léxicamente separados. Esto, por ejemplo, nos da una función eficiente aparentemente trivial para seleccionar los principales elementos k de una lista desordenada:

select :: Ord a => Int -> [a] -> [a]
select k = take k . sort

Simplemente usamos nuestra función de clasificación normal y confiamos en la pereza para no ordenar toda la lista. Mientras la función de clasificación sea razonable en sí misma, esto tiene las asintóticas que queremos.

Un enfoque interesante, pero que limita con “lindo” y no es tan difícil en Python. (En parte porque Python tiene la pereza incorporada, ¡pero solo para listas, en forma de generadores!)

Queremos algo un poco más interesante, confiando en la pereza de maneras menos triviales. ¿Qué es la pereza buena en el modelado que es difícil sin ella? Infinito , especialmente infinito en el sentido de precisión arbitraria: piense en números reales. Aquí hay un problema interesante, quizás un poco cruel para una entrevista: diseñe un tipo de datos para almacenar las posiciones de los objetos en dos dimensiones con precisión arbitraria . Esto podría usarse, por ejemplo, para gráficos que son independientes de la resolución. Queremos indexar en un área continua ([math] \ mathbb {R} ^ 2 [/ math]) de una manera más simple y más general que algo como SVG.

Entonces cómo podemos hacer esto? Bueno, una forma razonable de representar datos de imagen en dos dimensiones es usar un quadtree. Un quadtree indexa un espacio bidimensional dividiéndolo repetidamente en cuadrantes. Las áreas que contienen más detalles se dividen en cuadrantes más pequeños y las áreas sin muchos detalles se dejan en una resolución más alta.


Un quadtree finito para un rayo. Imagen en el dominio público de Wikimedia.

Para obtener una resolución arbitraria, podemos usar un quadtree perezoso con una profundidad potencialmente ilimitada. Cuando necesitamos renderizarlo en una resolución dada, lo evaluamos hasta cierto límite, aproximándonos (es decir, suavizado) después de eso. El caso más simple, solo píxeles en blanco y negro, con “gris” como nuestra aproximación, es lo suficientemente corto como para caber cómodamente en una entrevista. Los casos más complejos, como los colores arbitrarios, serán más complicados y complicados, pero seguirán la misma idea general.

La razón por la que esto es fácil con Haskell es que los tipos de datos son vagos por defecto. Un quadtree potencialmente infinito que podemos evaluar en parte es … solo un quadtree normal:

data Quad = White
| Black
| Gray Quad Quad Quad Quad

Ser realmente capaz de hacer cuadráticos infinitamente profundos es realmente útil: podemos tener una función de círculo único que produzca un árbol cuádruple de un círculo que rinda lo mejor posible para cualquier resolución . Las mismas capacidades que SVG pero más simple y más general. Podríamos usar estos árboles infinitos como cualquier otro dato de imagen, combinándolos con otros cuadrantes y nunca perdiendo información hasta el final: las operaciones se componen sin problemas y cualquier redondeo es explícitamente parte de la representación, no parte de la representación de datos en sí.

Escribir operaciones sobre Quad es una cuestión de coincidencia de patrones y es bastante sencillo de una manera similar al evaluador de expresiones que mostré anteriormente.

Algunas operaciones requieren un poquito de inteligencia para mantenerlas flojas; desea evitar cualquier operación que requiera leer en toda la profundidad del árbol, como simplificar nodos iguales en otros más grandes. Pero tener esto en mente no es difícil, es algo que viene con un poco de práctica. Terminas con transformaciones de cuadrúpedos muy perezosas que puedes encadenar tanto como quieras y, gracias a la magia de la evaluación perezosa, solo se realizará una cantidad suficiente de cada paso para obtener el resultado final que deseas. ¡Nunca necesitas calcular todo, lo cual es genial porque todo podría extenderse para siempre!

Incluso esto ya va a ser significativamente más difícil sin pereza. Ni siquiera sabría por dónde comenzar en Python, excepto quizás tratando de emular a Haskell, mal. Pero en realidad podemos llevar este problema un paso más allá: ¿qué sucede si queremos expandir nuestro código para que tenga un alcance ilimitado ? Es decir, ¿qué pasa si no solo queremos apoyar resoluciones arbitrarias sino también
un lienzo infinito?

Para que esto funcione, por supuesto, no podemos almacenar todo el lienzo en la memoria, no tenemos que evaluar más de lo que necesitamos renderizar. Suena como más pereza!

¿Por qué es útil esto? Bueno, nos permite expresar cosas útiles como cuadrículas o curvas que llenan espacios o fractales o ruido de una manera que se puede combinar con otras imágenes, sin importar cuán grandes sean esas imágenes. Y la lógica es útil incluso si no nos importan las extensiones totalmente infinitas: nos permitirá realizar operaciones en imágenes grandes, reutilizar sub-partes de esas imágenes en otros lugares y luego solo calcular el mínimo que necesitamos para obtener cualquier resultado dado . El código de imagen escrito de esta manera es sumamente componible y modular .

Para obtener una resolución ilimitada, teníamos un árbol perezoso que podía extenderse tanto como quisiera. Para renderizarlo, solo evaluamos tan lejos como sea necesario para alcanzar nuestra resolución deseada, y luego nos aproximamos. Sin embargo, el área que estamos evaluando está inherentemente limitada: el árbol con el que comenzamos es el más grande y todos sus subárboles, una secuencia potencialmente infinita, representan regiones cada vez más pequeñas.

Lo que necesitamos es un árbol que sea perezoso en ambas direcciones: podemos viajar tan lejos como queramos y podemos viajar tan alto como queramos. Cuanto más alto vamos, más grande es la región cubierta. Para renderizar una región dada a una resolución dada, primero ascenderíamos lo suficientemente alto como para cubrir esa región y luego descenderíamos lo suficientemente bajo como para alcanzar nuestra resolución. En cada paso, confiamos en la pereza para no calcular nada extraño .

Para ser justos, esto sigue siendo complicado incluso en Haskell. Pero es accesible o incluso, si conoce la abstracción correcta, sistemática . Para subir y bajar un árbol (o cualquier otro tipo), podemos usar una cremallera y podemos derivar la cremallera para cualquier tipo mediante programación. (Fascinantemente, es el resultado de tomando la derivada del tipo ).

Confío bastante en estas ideas y podría resolverlas en Haskell en una entrevista, con algunas sugerencias y comentarios justos. Sería injusto esperar que alguien conozca todos los trucos, pero pasar de “quadtree perezoso” o “quadtree perezoso con cremallera” al tipo y los argumentos correctos es sumamente razonable. Eso es más o menos lo que hice: obtuve la idea de alto nivel del blog de Conal, pero podría pasar de eso a una implementación real.

Ni siquiera puedo comenzar a imaginar hacer esto en Python. En el mejor de los casos, podría resolver todos los detalles en Haskell e intentar portarlos a Python, pero dudo que pueda llegar allí sin tropezar con un montón de errores. Intentar emular la pereza, los tipos de datos algebraicos y la coincidencia de patrones en un lenguaje que no admite ninguno de ellos es lamentable.

Entonces, hay un gran ejemplo de algo más fácil en Haskell que en Python: una representación de imagen con área ilimitada y resolución ilimitada , que nos permite componer y manipular imágenes sin perder ninguna información en el camino y finalmente calcular exactamente lo que necesitamos para renderizarlas sin hacer trabajo extraño

Creo que eso es genial. Y podría encajar totalmente una versión básica en una pregunta de entrevista. Pero si estás entrevistando a personas que no son auténticas con la pereza, es sádico y cruel.

Esto es difícil, porque Python es un gran lenguaje si no te importa demasiado el rendimiento (y a menudo no lo haces, porque incluso con lenguajes “lentos”, las computadoras siguen siendo bestias muy rápidas) y no esperes el programa para hacerse grande (> 1,000 LoC, o multi-desarrollador).

Si no está familiarizado con Haskell, y no necesita escribir implementaciones rápidas , debe usar Python para sus competiciones de código y entrevistas. Las razones por las que abogo por Haskell son el rendimiento, la arquitectura y el diseño. El idioma correcto para usar, por otro lado, en una entrevista de trabajo es el idioma que mejor conoces.

Supongo que uno podría explotar las infinitas colecciones de Haskell (debido a la pereza) para escribir un tamiz rápido de Eratóstenes. Por otro lado, si está familiarizado con los iteradores y generadores de Python, podría lograr lo mismo, solo que con más código.

Lo siento si esto sale como una no respuesta. Lo que pasa con los algoritmos es que generalmente son independientes del lenguaje. Haskell será más rápido que Python para la mayoría de los algoritmos, pero a menos que le importe el rendimiento por adelantado, Python estará bien. Lo que me atrae de Haskell es que todavía puedo entender el código a más de 20 kLoC, que nunca parece suceder en un lenguaje dinámico. Realmente necesito que la computadora haga gran parte del trabajo duro involucrado en la comprensión del código, porque simplemente no puedo tener tanta información en mi cabeza al mismo tiempo.

Creo que cualquier buen programador puede resolver cualquier problema algorítmico en cualquier idioma. Donde realmente comienza a importar, es cuando tienes grandes programas donde muchos algoritmos y tipos de datos diferentes interactúan entre sí.

Cuando descubro que una implementación de algún algoritmo particular es ineficiente, o tal vez incluso sutilmente incorrecta, en Python, y necesito agregar, eliminar o cambiar alguna clave en algún dict o alguna propiedad en alguna clase, ¿cómo puedo saber si se está arreglando? ¿El problema afectará al resto de mi programa? Puedo ejecutar las pruebas unitarias que yo (y mis compañeros de trabajo) hemos escrito; después de eso, tengo que impulsar la producción y esperar a que lleguen las excepciones inesperadas.

En Haskell, tengo una confianza mucho mayor de que mi programa es en realidad estructuralmente sólido, sin tener que depender de alguien que escriba el tipo correcto de prueba unitaria y sin tener que usar a mis clientes como probadores beta. Ahí es donde Haskell supera a Python en gran medida para grandes sistemas. (Además, genera un código más eficiente, por lo que necesito menos servidores para ejecutar la misma carga).

¡Uno de esos problemas es generar números de Hamming!

Decimos que n es un número de Hamming, si n no tiene un divisor primo mayor que 5. Entonces 75 = 3 * 5 * 5 es un número de Hamming, pero 35 = 5 * 7 no lo es.

Ahora, supongamos que quiero que me des los primeros números de k hamming:

La primera solución de solución de Python proporcionada por Rosetta Code es:

de itertools import islice

def hamming2 ():
” ‘\
Esta versión se basa en un fragmento de:
Dr. Dobb’s | Cosas buenas para desarrolladores serios: herramientas de programación, código, C ++, Java, HTML5, nube, móvil, pruebas

Cuando se expresa en algún pseudo-C imaginario con automático
asignación de almacenamiento ilimitada y aritmética BIGNUM, puede ser
expresado como:
hamming = h donde
matriz h;
n = 0; h [0] = 1; i = 0; j = 0; k = 0;
x2 = 2 * h [i]; x3 = 3 * h [j]; x5 = 5 * h [k];
repetir:
h [++ n] = min (x2, x3, x5);
if (x2 == h [n]) {x2 = 2 * h [++ i]; }
if (x3 == h [n]) {x3 = 3 * h [++ j]; }
if (x5 == h [n]) {x5 = 5 * h [++ k]; }
” ‘
h = 1
_h = [h] # memorizado
multiplicadores = (2, 3, 5)
multindeces = [0 para i en multiplicadores] # index en _h para multiplicadores
multvalues ​​= [x * _h [i] para x, i en zip (multiplicadores, múltiples entradas)]
rendimiento h
mientras cierto:
h = min (valores múltiples)
_h.append (h)
para (n, (v, x, i)) en enumerate (zip (valores múltiples, multiplicadores, entradas múltiples)):
si v == h:
i + = 1
multindeces [n] = i
valores múltiples [n] = x * _h [i]
# tapa la memorización
mini = min (múltiples entradas)
si mini> = 1000:
del _h [: mini]
multindeces = [i – mini para i en multindeces]
# #
rendimiento h

y aquí está la primera solución provista en Haskell:

hamming = 1: mapa (2 *) hamming `union` map (3 *) hamming` union` map (5 *) hamming

union [correo electrónico protegido] (x: xs) [correo electrónico protegido] (y: ys) = caso comparar xy de
LT -> x: unión xs b
EQ -> x: unión xs ys
GT -> y: union a ys

Poder describir las cosas como un espacio infinito que exploras selectivamente es uno de los puntos fuertes más obvios de la falta de rigor de Haskell.

Realmente me gustó la primera solución en el artículo del problema de la Cascada de Chris Done, problema de flujo de agua de Twitter y loeb, pero las soluciones posteriores también son lindas 🙂

La biblioteca de diferenciación automática de Ed Kmett también es bastante convincente, creo. Diferenciación automática | Hackage

Eso es lo que viene a la mente, no soy un genio de los algos.

Un ejemplo de este tipo, como mencionó Chris Allen, es que en Haskell podría definir y usar estructuras de datos infinitas e inspeccionarlas hasta el elemento N-ésimo (egas un estudiante que implementé un árbol Stern-Brocot). Ese es un ejemplo.