Получение упорядоченных листьев дерева в схеме

#scheme #lisp #racket #sicp

Вопрос:

Я выполняю упражнение, чтобы захватить «листья» вложенного списка в схеме (из SICP). Вот упражнение ввод-вывод:

 (define x (list (lis 1 2) (list 3 4)))
(fringe x)
; (1 2 3 4)

(fringe (list x x))
; (1 2 3 4 1 2 3 4)
 

Теперь я придумал два ответа на этот вопрос: один рекурсивный и один итеративный. Вот мои две реализации ниже:

 (define (fr lst)
  (cond ((null? lst) '())
        ((not (pair? (car lst))) (cons (car lst) (fr (cdr lst))))
        (else (append (fr (car lst)) (fr (cdr lst))))))
 
 (define (add-element-to-list lst elem)
  (if (null? lst)
      (list elem)
      (cons (car lst) (add-element-to-list (cdr lst) elem))))

(define (fringe lst)
 (define L '())
  (define (iter lst)
    (if (not (pair? (car lst)))
        (set! L (add-element-to-list L (car lst))) ; update the list if it's a leaf
        (iter (car lst)))                          ; otherwise recurse
    (if (not (null? (cdr lst))) (iter (cdr lst)))  ; and if we have a cdr, recurse on that
    L
    )
  (iter lst)
)
 
 (fringe x)
(fr x)
(fr (list x x))
(fringe (list x x))
; (1 2 3 4)
; (1 2 3 4)
; (1 2 3 4 1 2 3 4)
; (1 2 3 4 1 2 3 4)
; OK
 

Проблема для меня в том, что это упражнение заняло у меня целую вечность, чтобы разобраться с тонной ударов по голове на этом пути (и мне все еще трудно «понять», когда я пишу это). Вот несколько вещей, с которыми я боролся, и посмотрим, есть ли какие-либо предложения о способах решения этих проблем в схеме:

  1. Сначала я думал, что есть два случая. Обычный/скалярный случай и вложенный случай. Однако, похоже, что на самом деле их три! Есть обычный случай, вложенный случай, а затем нулевой случай-и во внутренних списках также есть нулевой случай! Есть ли хороший общий шаблон или что-то, что объясняет нулевой случай? Это то, о чем часто говорят?
  2. В итеративном случае, почему я должен возвращаться L в конце? Почему (iter lst) бы просто не вернуть это (т. Е., если я удалил автономный — L в нижней части iter функции).
  3. Наконец, существует ли «более чистый» способ реализации итеративного случая? Похоже, у меня так много кода, где его, вероятно, можно было бы улучшить.

Комментарии:

1. Не используйте set! . Есть веские причины для того, чтобы SICP еще не представил его.

Ответ №1:

Причина, по которой существует три случая, заключается в том, что вы импортируете некоторое скалярное / векторное различие из какого-либо другого языка: в схеме его нет, и это бесполезно. Вместо этого список-это рекурсивно определенный объект: список-это либо пустой список, либо пара чего-то и список. Это означает, что необходимо провести два различия, а не одно: является ли объект парой и является ли объект пустым списком:

 (define (lyst? o)
  (or (null? o)
      (and (pair? o) (lyst? (cdr o)))))
 

Это совершенно отличается от векторного / скалярного различия. Я не знаю, с какого языка вы это получаете, но просто подумайте о том, как будет работать математика: векторы определяются над некоторым скалярным полем, и нет вектора, который также является скаляром. Но для списков есть список, который не является парой. Просто перестаньте думать о векторах и скалярах: это не очень полезный способ думать о списках, парах и пустом списке.

Итеративная версия слишком ужасна, чтобы думать о ней: есть причина, по которой SICP еще не представлен set! .

Во-первых, на самом деле это не итеративно: как и большинство «итеративных» решений этой проблемы в сети, это выглядит так, как будто это так, но это не так. Причина, по которой это не так, заключается в том, что скелет iter функции выглядит так

  1. если бла
    • рекурсия по первому элементу списка
    • в противном случае сделайте что-нибудь другое
  2. если другое бла
    • выполните итерацию по остальной части списка

И самое главное, что и (1), и (2) всегда происходят, поэтому вызов в машину списка не является хвостовым вызовом: это полноценный рекурсивный вызов.

Тем не менее, вы можете сделать это намного лучше: абсолютно стандартный способ сделать это-использовать аккумулятор:

 (define (fringe l)
  (define (fringe-loop thing accum)
    (cond
      ((null? thing)
       ;; we're at the end of the list or an element which is empty list
       accum)
      ((pair? thing)
       ;; we need to look at both the first of the list and the rest of the list
       ;; Note that the order is rest then first which means the accumulator
       ;; comes back in a good order
       (fringe-loop (car thing)
                    (fringe-loop (cdr thing) accum)))
      (else
       ;; not a list at all: collect this "atomic" thing
       (cons thing accum))))
  (fringe-loop l '()))
 

Обратите внимание, что при этом строится пограничный (линейный) список снизу вверх, что является естественным способом построения линейных списков с рекурсией. Чтобы достичь этого, он немного хитро упорядочивает то, как он смотрит на вещи, чтобы результаты получались в правильном порядке. Обратите также внимание, что это также не итеративно: это рекурсивно из-за (fringe-loop ... (fringe-loop ...)) вызова. Но на этот раз все гораздо яснее.

Причина, по которой он не является итеративным, заключается в том, что процесс поиска (древовидного, Lisp) списка не является итеративным: это то, что SICP назвал бы «рекурсивным процессом», потому что (древовидные) списки Lisp рекурсивно определены как в их первом, так и в остальных полях. Ничто из того, что вы можете сделать, не сделает процесс итеративным.

Но вы можете сделать код итеративным на уровне реализации, явно управляя стеком, тем самым превращая его в хвостовую рекурсивную версию. Однако характер вычислительного процесса не меняется:

 (define (fringe l)
  (define (fringe-loop thing accum stack)
    (cond
      ((null? thing)
       ;; ignore the () sentinel or () element
       (if (null? stack)
           ;; nothing more to do
           accum
           ;; continue with the thing most recently put aside
           (fringe-loop (car stack) accum (cdr stack))))
      ((pair? thing)
       ;; carry on to the right, remembering to look to the left later
       (fringe-loop (cdr thing) accum (cons (car thing) stack)))
      (else
       ;; we're going to collect this atomic thing but we also need 
       ;; to check the stack
       (if (null? stack)
           ;; we're done
           (cons thing accum)
           ;; collect this and continue with what was put aside
           (fringe-loop (car stack) (cons thing accum) (cdr stack))))))
  (fringe-loop l '() '()))
 

Стоит ли это того, зависит от того, насколько дорогими вы считаете рекурсивные вызовы и существует ли какой-либо предел рекурсии. Однако общий трюк явного управления тем, что вы собираетесь делать дальше, в целом полезен, поскольку он может значительно упростить управление порядком поиска.

(Обратите внимание, конечно, что вы можете проделать подобный трюк для любой программы вообще!)

Комментарии:

1. В SICP они используют формулировки «рекурсивный процесс» и «итеративный процесс». Использование явного стека не делает его «итеративным процессом». Это невозможно сделать, так как процесс по своей сути рекурсивен.

2. @Сильвестр: да, вы правы (вы имеете в виду «… по своей сути рекурсивный», я думаю, хотя). Я изменил формулировку, чтобы сделать ее лучше, я надеюсь. Спасибо.

3. @tfb спасибо за такой обстоятельный ответ. Пара вопросов. Я использую #lang sicp . (1) Я предполагаю first car rest , что это псевдоним и является псевдонимом для cdr ? (2) Что делают скобки? Например, [[(null? thing) accum] . Я никогда раньше не видел шляпу. Наконец, (3) почему использование set! ужасно? Или вы просто имеете в виду, что мой код в итеративной версии ужасен и set! хорош?

4. @David542: Да, я использую first / rest , когда думаю об обработке списков, и car / cdr , когда я думаю о минусах, но они одинаковы. [...] Насколько я знаю, они такие же, как и родители в Рэкете, но только совпадают друг с другом, и людям (и DrRacket) нравится использовать их, чтобы сделать код «более читаемым», возможно. Я отредактировал оба, хотя настоятельно рекомендовал бы использовать first различие / rest vs car / cdr . Что касается set! , нет, я был груб по поводу использования присваивания в целом, а не вашего кода. Я думаю, что это распространенное мнение среди людей, занимающихся функциональным программированием.

5. Я думаю, что если это O(n), то это «итерационный процесс». перечисление узлов дерева равно O(n) (где n — количество узлов, но не листьев). «по своей сути рекурсивный», я думаю, означает вдвойне рекурсивный (или еще хуже), как наивный Фибоначчи.


Ответ №2:

Все дело в типах людей. Принципиальное развитие следует типам. Тогда это становится легко.

Lisp-это нетипизированный язык. Это как ассемблер на стероидах. Нет никаких типов, никаких ограничений на то, что вы можете кодировать.

В языке нет типов, навязываемых языком, но концептуально типы все же существуют. Мы кодируем типы, мы обрабатываем типы, мы создаем значения в соответствии с заданными спецификациями, т. е. Значения некоторых типов, необходимые для правильного взаимодействия частей более крупной системы, для правильной совместной работы функций, которые мы пишем, и т. Д. и т. Д.

Из чего мы строим fringe дом? Это «список»?

Что такое «список»? Это

 (define (list? ls)
  (or (null? ls)
      (and (pair? ls)
           (list? (cdr ls)))))
 

Это то, из чего мы строим fringe дом? Почему это ничего не говорит об car этой вещи, должны ли мы игнорировать все, что в car ней есть ? Почему бы и нет, конечно, нет. Мы не преобразуем список. Мы на самом деле преобразуем дерево:

 (define (tree? ls)
  (or (null? ls)
      (and (pair? ls)
           (tree? (car ls))
           (tree? (cdr ls)))))
 

Действительно ли этого достаточно, чтобы () в нем было только s? Скорее всего, нет.

Это

 (define (tree? ls)
  (or (null? ls)
      (not (pair? ls))   ;; (atom? ls) is what we mean
      (and ;; (pair? ls)
           (tree? (car ls))
           (tree? (cdr ls)))))
 

По 1 tree? -видимому, так оно и есть, но давайте пока отложим это в сторону.

То, что мы имеем здесь, — это структурированный, принципиальный способ рассматривать фрагмент данных как принадлежащий определенному типу. Или, как некоторые говорят, тип данных.

Поэтому мы просто следуем тому же скелету определения / предиката типа данных, чтобы написать функцию, которая должна обрабатывать значения указанного типа определенным образом (это подход, продвигаемый Стерлингом и Шапиро «Искусство пролога»).

 (define (tree-fringe ls)
 

Итак, что же это такое-производить? Список атомов в его листьях, вот что.

   (cond 
      ((null? ls)
 

А () -это уже а list? .

                    ls)
      ((not (pair? ls))   ;; (atom? ls) is what we mean
           (handle-atom-case ls))
 

Давай пока отложим это. Переходим к следующему делу,

       (else
           ;; (tree? (car ls))
           ;; (tree? (cdr ls))
 

оба car и cdr из ls являются tree? s. Как с ними обращаться, мы уже знаем. Это

           (let ((a (tree-fringe (car ls)))
                (b (tree-fringe (cdr ls)))
 

и что нам делать с этими двумя частями? Мы собираем их воедино. Сначала идет бахрома слева, затем справа. Простой:

             (append   a   b  )))))

(define (handle-atom-case ls)  
      ;; bad name, inline its code inside 
      ;; the `tree-fringe` later, when we have it
 

Итак, какой тип данных append ожидается в обоих его аргументах? А list? , еще раз.

И это то, что мы должны создать для атомарного «дерева». Такое «дерево» — это его собственная бахрома. Кроме,

     ;;  tree:       1         2
    ;; fringe:    ( 1 )     ( 2 )
 

должно быть list? , это А. На самом деле довольно просто превратить атомарный фрагмент данных, любые данные, в list? содержащий этот фрагмент данных.

       ........ )
 

И это была единственная нетривиальная вещь, которую мы должны были придумать здесь, чтобы найти решение.

Рекурсия-это разбиение материала на части, которые похожи на целое, преобразование их с помощью той же процедуры, которую мы пытаемся написать, а затем объединение результатов каким-то простым и понятным способом.

Если а tree? содержит два меньших trees? , что ж, мы сорвали джекпот-мы уже знаем, как с ними обращаться!

И когда у нас есть структурные типы данных, у нас уже есть способ их различать. В любом случае, это то, как они определены.


Может быть, я отвечу на ваш второй вопрос позже.