Haskell과 Scheme이 단일 연결 목록을 사용하는 이유는 무엇입니까?


12

이중 연결 목록은 최소한의 오버 헤드 (셀당 다른 포인터)를 가지며 양쪽 끝에 추가하고 앞뒤로 이동할 수 있으며 일반적으로 많은 즐거움을 누릴 수 있습니다.


목록 생성자는 원래 목록을 수정하지 않고 단일 연결 목록의 시작 부분에 삽입 할 수 있습니다. 기능 프로그래밍에 중요합니다. 이중 연결 목록은 수정이 필요하며 이는 매우 순수하지 않습니다.
tp1

3
그것에 대해 생각하십시오. 어떻게 이중으로 연결된 불변 목록을 만들겠습니까? 당신은 가질 필요가 next다음 요소와에 이전 요소 점의 포인터를 prev이전 요소에 다음 요소 점의 포인터. 그러나이 두 요소 중 하나가 다른 요소보다 먼저 생성되므로 해당 요소 중 하나에 아직 존재하지 않는 객체를 가리키는 포인터가 있어야합니다! 먼저 하나의 요소를 만든 다음 다른 요소를 만든 다음 포인터를 설정할 수 없습니다. 즉, 변경할 수 없습니다. (참고 : "매듭을 묶는 것"이라는 게으름을 이용하는 방법이 있다는 것을 알고 있습니다.
Jörg W Mittag

1
대부분의 경우 이중 연결 목록은 일반적으로 필요하지 않습니다. 반대로 역순으로 액세스해야하는 경우 목록의 항목을 스택으로 푸시하고 O (n) 반전 알고리즘을 위해 하나씩 팝합니다.
Neil

답변:


23

좀 더 깊게 보이면 실제로 기본 언어로 배열을 포함합니다.

  • 5 번째 개정 된 계획 보고서 (R5RS)에는 벡터 유형이 포함되어 있으며 , 이는 임의 액세스에 대한 선형 시간보다 나은 고정 크기 정수 색인 모음입니다.
  • Haskell 98 보고서는 배열 유형 도 있습니다.

그러나 함수형 프로그래밍 명령은 배열 또는 이중 연결 목록보다 단일 연결 목록을 오랫동안 강조해 왔습니다. 사실, 지나치게 강조했을 가능성이 높습니다. 그러나 몇 가지 이유가 있습니다.

첫 번째는 단일 연결 목록이 가장 단순하면서도 가장 유용한 재귀 데이터 형식 중 하나라는 것입니다. Haskell의 목록 유형에 해당하는 사용자 정의 항목은 다음과 같이 정의 할 수 있습니다.

data List a           -- A list with element type `a`...
  = Empty             -- is either the empty list...
  | Cell a (List a)   -- or a pair with an `a` and the rest of the list. 

리스트가 재귀 데이터 유형이라는 사실은리스트에서 작동하는 함수가 일반적으로 구조적 재귀를 사용한다는 것을 의미합니다 . 하스켈 측면에서 : 당신 패턴 목록 생성자에 일치, 당신은 재귀 서브 파트 목록. 이 두 가지 기본 함수 정의에서 변수 as를 사용 하여 목록의 꼬리를 참조합니다. 재귀 호출은 목록 아래로 "내림차순"입니다.

map :: (a -> b) -> List a -> List b
map f Empty = Empty
map f (Cell a as) = Cell (f a) (map f as)

filter :: (a -> Bool) -> List a -> List a
filter p Empty = Empty
filter p (Cell a as)
    | p a = Cell a (filter p as)
    | otherwise = filter p as

이 기술은 함수가 모든 유한리스트에 대해 종료되도록 보장하고 문제를 해결하는 좋은 기술이기도합니다. 문제를 더 간단하고 더 테너 블 한 하위 부분으로 자연스럽게 나누는 경향이 있습니다.

따라서 단일 링크 목록은 아마도 학생들에게 이러한 기술을 소개 할 수있는 최고의 데이터 유형일 것입니다. 이는 함수형 프로그래밍에서 매우 중요합니다.

두 번째 이유는 "단일 연결 목록이 필요한 이유"가 아니라 "이중 연결 목록 또는 배열이 아닌 이유"가되는 이유입니다. 후자의 데이터 유형은 종종 종종 함수 프로그래밍이 가능한 돌연변이 (수정 가능한 변수)를 요구합니다. 멀리 떨어져 있습니다. 그래서 일어날 때 :

  • Scheme과 같은 열성적인 언어에서는 돌연변이를 사용하지 않고 이중 연결 목록을 만들 수 없습니다.
  • Haskell과 같은 게으른 언어에서는 돌연변이를 사용하지 않고 이중 연결 목록을 만들 수 있습니다. 그러나 그 목록을 기반으로 새 목록을 만들 때마다 원본의 모든 구조가 아니라면 대부분을 복사해야합니다. 단일 링크 목록을 사용하는 경우 "구조 공유"를 사용하는 함수를 작성할 수 있습니다. 새 목록은 적절한 경우 이전 목록의 셀을 재사용 할 수 있습니다.
  • 일반적으로 배열을 변경 불가능한 방식으로 사용한 경우 배열을 수정할 때마다 전체 내용을 복사해야했습니다. vector그러나 최근의 Haskell 라이브러리 는이 문제를 크게 개선하는 기술을 찾았습니다.

세 번째이자 마지막 이유는 주로 Haskell과 같은 게으른 언어에 적용됩니다. 게으른 단일 연결 목록은 실제로 메모리 내 목록보다 반복자 와 더 유사합니다 . 코드가 목록의 요소를 순차적으로 소비하고 진행하면서 제거하는 경우 객체 코드는 목록을 진행할 때 목록 셀과 해당 내용 만 구체화합니다.

즉, 전체 목록이 한 번에 메모리에 존재할 필요는 없으며 현재 셀만 존재할 수 있습니다. 현재 셀 이전의 셀은 가비지 수집 될 수 있습니다 (이중 연결 목록에서는 불가능). 현재 셀보다 이후의 셀은 도착할 때까지 계산할 필요가 없습니다.

그것보다 훨씬 더 나아갑니다. fusion 이라는 널리 사용되는 Haskell 라이브러리 에는 컴파일러가 목록 처리 코드를 분석하고 순차적으로 생성되고 소비 된 중간 목록을 찾아서 버리는 기술이 있습니다. 이 지식으로 컴파일러는 목록 셀의 메모리 할당을 완전히 제거 할 수 있습니다. 이는 컴파일 후 Haskell 소스 프로그램의 단일 링크 목록이 실제로 데이터 구조 대신 루프 로 바뀔 수 있음을 의미합니다 .

Fusion은 위에서 언급 한 vector라이브러리가 불변 배열에 대한 효율적인 코드를 생성하는 데 사용 하는 기술이기도합니다 . Haskell의 매우 고유하지 않은 기본 유형 ( 단일 연결 문자 목록과 동일)을 대체하여 빌드 된 매우 인기있는 bytestring(바이트 배열) 및 text(유니 코드 문자열) 라이브러리도 String마찬가지 [Char]입니다. 따라서 현대 Haskell에는 융합 지원이 가능한 불변 배열 유형이 매우 보편적 인 추세가 있습니다.

리스트 융합은 단일 링크리스트에서 앞으로 나아갈 수는 있지만 결코 뒤로 갈 수 없다는 사실에 의해 촉진됩니다 . 이는 함수형 프로그래밍에서 매우 중요한 주제를 제시합니다. 데이터 형식의 "모양"을 사용하여 계산의 "모양"을 도출합니다. 요소를 순차적으로 처리하려는 경우 단일 연결 목록은 구조적 재귀와 함께 사용할 때 매우 자연스럽게 액세스 패턴을 제공하는 데이터 유형입니다. "분할 및 정복"전략을 사용하여 문제를 공격하려는 경우 트리 데이터 구조가이를 잘 지원하는 경향이 있습니다.

많은 사람들이 조기에 기능 프로그래밍 마차에서 빠져 나와서 단일 링크 목록에 노출되지만 고급 기본 아이디어에는 노출되지 않습니다.


1
정말 좋은 답변입니다!
Elliot Gorokhovsky

14

그들은 불변성과 잘 작동하기 때문입니다. 두 불변의 목록을 가지고, 가정 [1, 2, 3][10, 2, 3]. 목록의 각 항목이 항목을 포함하는 노드이고 나머지 목록에 대한 포인터 인 단일 연결 목록으로 표시됩니다.

node -> node -> node -> empty
 1       2       3

node -> node -> node -> empty
 10       2       3

[2, 3]부분이 어떻게 동일합니까? 변경 가능한 데이터 구조를 사용하면 새로운 데이터를 작성하는 코드가 다른 데이터를 사용하는 코드에 영향을 줄 필요가 없기 때문에 두 개의 서로 다른 목록입니다. 그러나 불변 데이터를 사용 하면 목록의 내용이 절대 변경되지 않으며 코드가 새로운 데이터를 작성할 수 없다는 것을 알고 있습니다. 그래서 우리는 꼬리를 재사용 할 수 있고 두리스트가 그들의 구조의 일부를 공유하게 할 수 있습니다 :

node -> node -> node -> empty
 1      ^ 2       3
        |
node ---+
 10

두 목록을 사용하는 코드는 변경하지 않기 때문에 한 목록이 다른 목록에 영향을주는 것에 대해 걱정할 필요가 없습니다. 이것은 또한리스트의 앞에 아이템을 추가 할 때 완전히 새로운리스트를 복사하고 만들 필요가 없다는 것을 의미합니다.

그러나 시도하고 표현하는 경우 [1, 2, 3][10, 2, 3]같은 이중 연결리스트 :

node <-> node <-> node <-> empty
 1       2       3

node <-> node <-> node <-> empty
 10       2       3

이제 꼬리는 더 이상 동일하지 않습니다. 첫 번째 [2, 3]1머리에 대한 포인터를 가지고 있지만 두 번째는에 대한 포인터를 가지고 10있습니다. 또한 목록 헤드에 새 항목을 추가하려면 목록의 이전 헤드를 변경하여 새 헤드를 가리켜 야합니다.

다중 헤드 문제는 각 노드에 알려진 헤드 목록을 저장하고 새 목록을 작성하여 수정하도록함으로써 잠재적으로 해결 될 수 있지만 헤드가 다른 목록의 버전이있을 때 해당 목록을 가비지 콜렉션 주기로 유지 보수해야합니다. 다른 코드 조각에 사용되어 수명이 다릅니다. 복잡성과 오버 헤드가 추가되며 대부분 가치가 없습니다.


8
당신이 의미하는 것처럼 꼬리 공유는 발생하지 않습니다. 일반적으로 아무도 메모리의 모든 목록을 검토하지 않고 공통 접미사를 병합 할 기회를 찾지 않습니다. 공유는 방금 발생합니다 . 예를 들어, 매개 변수가있는 함수가 한 곳 에서 다른 곳에서 xs구성 1:xs되는 경우 알고리즘이 작성되는 방식에서 벗어 납니다10:xs .

0

@sacundim의 대답은 대부분 사실이지만 언어 디자인과 실제 요구 사항에 대한 상충 관계에 대한 다른 중요한 통찰력도 있습니다.

객체와 참조

이 언어는 일반적으로 언 바운드 가진 개체를 의무화 (또는 가정) 동적 범위를 (또는 C의 말투에 수명 의 의미의 차이로 인해 동일한 것은 아니지만, 객체 다음 언어 중에서는, 아래 참조) (일류 참조를 피하고, 기본적으로 예를 들어 C의 객체 포인터) 및 의미 규칙의 예측할 수없는 동작 (예 : 의미와 관련된 ISO C의 정의되지 않은 동작)

또한, 이러한 언어로 된 (일류) 객체의 개념은 보수적으로 제한적입니다. 기본적으로 "위치"속성은 지정되고 보장되지 않습니다. 이것은 객체가 바인딩되지 않은 동적 범위가없는 일부 ALGOL 유사 언어 (예 : C 및 C ++)에서 완전히 다릅니다. 여기서 객체는 기본적으로 메모리 위치와 결합 된 일종의 "유형 스토리지"를 의미합니다.

객체 내에서 스토리지를 인코딩하는 것은 수명 동안 결정 론적 계산 효과를 첨부하는 것과 같은 몇 가지 추가 이점이 있지만 또 다른 주제입니다.

데이터 구조 시뮬레이션 문제

일류 참조가 없으면 단일 연결 목록은 이러한 데이터 구조의 표현 특성과 이러한 언어의 제한된 기본 연산으로 인해 많은 기존 (열심하고 변경 가능한) 데이터 구조를 효과적이고 이식 가능하게 시뮬레이션 할 수 없습니다. 반대로 C에서는 엄격하게 호환되는 프로그램 에서도 링크 된 목록을 매우 쉽게 파생시킬 수 있습니다 . 또한 배열 / 벡터와 같은 대체 데이터 구조는 실제로는 단일 링크 된 목록과 비교하여 몇 가지 우수한 속성을 갖습니다. 이것이 R 5 RS가 새로운 원시 작업을 도입하는 이유 입니다.

그러나 벡터 / 배열 유형과 이중 연결 목록에는 차이가 있습니다. 배열은 종종 O (1) 액세스 시간 복잡성과 적은 공간 오버 헤드로 가정되며 목록과 공유되지 않는 우수한 속성입니다. (엄격하게 말하면, ISO C는 보장하지 않지만 사용자는 거의 항상 그것을 기대하며 실제 구현은 이러한 암시 적 보증을 너무 명백하게 위반하지 않습니다.) 이중 연결 목록은 종종 단일 속성 목록보다 두 속성을 더 나쁘게 만듭니다. 역방향 / 앞으로 반복은 오버 헤드가 훨씬 적은 배열 또는 벡터 (정수 인덱스와 함께)에 의해 지원됩니다. 따라서 이중 연결 목록은 일반적으로 더 잘 수행되지 않습니다. 여전히 더 나쁘지만 기본 구현 환경 (예 : libc)에서 제공하는 기본 할당자를 사용할 때 목록의 동적 메모리 할당에 대한 캐시 효율성 및 대기 시간에 대한 성능은 어레이 / 벡터의 성능보다 치명적입니다. 따라서 이러한 객체 생성을 크게 최적화하는 매우 구체적이고 "영리한"런타임이 없으면 배열 / 벡터 유형이 종종 링크 된 목록보다 선호됩니다. 예를 들어 ISO C ++를 사용하면 다음과 같은주의 사항이 있습니다.std::vector선호한다 std::list기본적으로.) 따라서, 특별히 지원 (doubly-) 연결리스트를 새로운 프리미티브를 소개하는 것은 확실히 지원 배열 / 연습 벡터 데이터 구조 할 수 있도록 도움이되지 않습니다.

공정하게 말하면,리스트는 여전히 배열 / 벡터보다 더 나은 특정 속성을 가지고 있습니다 :

  • 목록은 노드 기반입니다. 목록에서 요소를 제거해도 다른 노드의 다른 요소에 대한 참조 는 무효화 되지 않습니다 . (이는 일부 트리 또는 그래프 데이터 구조에도 적용됩니다.) OTOH, 배열 / 벡터는 무효화되는 후행 위치를 참조 할 수 있습니다 (일부 경우 대규모 재 할당).
  • O (1) 시간에 목록을 스플 라이스 할 수 있습니다 . 현재 배열로 새로운 배열 / 벡터를 재구성하는 데 훨씬 많은 비용이 듭니다.

그러나 이러한 속성은 단일 연결 목록 지원 기능이 내장 된 언어에는 이미 그다지 중요하지 않습니다. 객체의 동적 범위가 규정 된 언어 (일반적으로 댕글 링 참조를 유지하는 가비지 수집기가 있음)의 언어에는 여전히 차이가 있지만 의도에 따라 무효화도 덜 중요 할 수 있습니다. 따라서 이중 연결 목록에서이기는 유일한 경우는 다음과 같습니다.

  • 비재 할당 보장 및 양방향 반복 요구 사항이 모두 필요합니다. (요소 액세스 성능이 중요하고 데이터 세트가 충분히 큰 경우 대신 이진 검색 트리 또는 해시 테이블을 선택합니다.)
  • 효율적인 양방향 스플 라이스 작업이 필요합니다. 이것은 매우 드 rare니다. (브라우저에서 선형 기록 레코드와 같은 것을 구현하는 경우에만 요구 사항을 충족합니다.)

불변성과 앨리어싱

Haskell과 같은 순수한 언어에서는 객체를 변경할 수 없습니다. 체계의 대상은 종종 돌연변이없이 사용됩니다. 이러한 사실을 통해 객체 인터 닝으로 메모리 효율성을 효과적으로 향상시킬 수 있습니다. 즉, 동일한 값으로 여러 객체를 암시 적으로 공유 할 수 있습니다.

이는 언어 디자인에서 공격적인 고급 최적화 전략입니다. 그러나 여기에는 구현 문제가 포함됩니다. 실제로 기본 스토리지 셀에 암시 적 별명을 도입합니다. 앨리어싱 분석이 더 어려워집니다. 결과적으로 일류가 아닌 참조의 오버 헤드를 제거 할 가능성이 적을 수 있습니다. 사용자조차도 전혀 참조하지 않습니다. Scheme과 같은 언어에서 돌연변이가 완전히 배제되지 않으면 병렬 처리를 방해합니다. 그래도 게으른 언어 (어쨌든 썽크 때문에 이미 성능 문제가 있음)에서는 괜찮을 수 있습니다.

범용 프로그래밍의 경우 이러한 언어 디자인 선택에 문제가있을 수 있습니다. 그러나 몇 가지 일반적인 기능적 코딩 패턴을 사용하면 언어가 여전히 잘 작동합니다.

당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.