연결 목록을 정렬하는 가장 빠른 알고리즘은 무엇입니까?


96

O (n log n)이 링크드리스트가 할 수있는 최선인지 궁금합니다.


31
아시다시피 O (nlogn)는 비교 기반 정렬의 경계입니다. O (n) 성능 (예 : 계수 정렬)을 제공 할 수있는 비 비교 기반 정렬이 있지만 데이터에 대한 추가 제약이 필요합니다.
MAK

"왜이 코드가 작동하지 않습니까 ?????"와는 다른 질문이 있던 시절이었습니다. 그래서 허용되었습니다.
Abhijit Sarkar

답변:


100

실행 시간 에 O (N log N)보다 더 잘할 수 없다고 예상하는 것이 합리적 입니다.

그러나 흥미로운 부분은 제자리 에서 안정적으로 정렬 할 수 있는지 , 최악의 경우 동작 등 을 조사하는 것 입니다.

Putty로 유명한 Simon Tatham 이 병합 정렬을 사용하여 연결 목록정렬하는 방법을 설명합니다 . 그는 다음과 같이 결론을 내립니다.

모든 자존심 정렬 알고리즘과 마찬가지로 실행 시간은 O (N log N)입니다. 이것은 Mergesort이기 때문에 최악의 실행 시간은 여전히 ​​O (N log N); 병리학 적 사례가 없습니다.

보조 스토리지 요구 사항은 작고 일정합니다 (예 : 정렬 루틴 내 몇 가지 변수). 배열에서 연결된 목록의 본질적으로 다른 동작 덕분에이 Mergesort 구현은 일반적으로 알고리즘과 관련된 O (N) 보조 저장 비용을 방지합니다.

또한 단일 및 이중 연결 목록 모두에서 작동하는 C 구현의 예가 있습니다.

@ Jørgen Fogh가 아래에서 언급했듯이 big-O 표기법은 메모리 지역성, 적은 수의 항목 등으로 인해 하나의 알고리즘이 더 나은 성능을 발휘할 수있는 몇 가지 상수 요소를 숨길 수 있습니다.


3
이것은 단일 연결 목록이 아닙니다. 그의 C 코드는 * prev와 * next를 사용하고 있습니다.
LE

3
@LE 실제로 둘 다 입니다. 에 대한 서명 listsort이 표시되면 매개 변수를 사용하여 전환 할 수 있음을 알 수 있습니다 int is_double.
csl

1
@LE는 : 여기 의 파이썬 버전 listsortC 코드를 지원하는 유일한 싱글 링크드리스트
JFS

O (kn)은 이론적으로 선형이며 버킷 정렬로 달성 할 수 있습니다. 합리적인 k (분류하는 객체의 비트 수 / 크기)를 가정하면 조금 더 빠를 수 있습니다.
Adam

74

여러 요인에 따라 목록을 배열에 복사 한 다음 Quicksort 를 사용하는 것이 실제로 더 빠를 수 있습니다 .

이것이 더 빠를 수있는 이유는 배열이 연결된 목록보다 캐시 성능이 훨씬 더 우수하기 때문입니다. 목록의 노드가 메모리에 분산되어있는 경우 모든 곳에서 캐시 미스가 생성 될 수 있습니다. 그런 다음 어레이가 크면 어쨌든 캐시 누락이 발생합니다.

병합 정렬이 더 잘 병렬화되므로 원하는 경우 더 나은 선택이 될 수 있습니다. 연결 목록에서 직접 수행하면 훨씬 빠릅니다.

두 알고리즘 모두 O (n * log n)에서 실행되기 때문에 정보에 입각 한 결정을 내리려면 실행하려는 시스템에서 두 알고리즘을 모두 프로파일 링해야합니다.

--- 편집하다

나는 내 가설을 테스트하기로 결정 clock()하고 연결된 int 목록을 정렬하는 데 걸린 시간을 측정하는 C 프로그램을 작성했습니다 . 나는 각 노드에 할당 된 연결리스트로 시도 malloc()캐시 성능이 더 좋을 것이다 있도록하고, 노드가 배열에 선형으로 배치 된 연결리스트. 나는 이것을 내장 qsort와 비교했는데, 여기에는 조각난 목록에서 배열로 모든 것을 복사하고 결과를 다시 복사하는 것이 포함되었습니다. 각 알고리즘은 동일한 10 개의 데이터 세트에서 실행되었으며 결과는 평균화되었습니다.

결과는 다음과 같습니다.

N = 1000 :

병합 정렬이있는 조각난 목록 : 0.000000 초

qsort가있는 어레이 : 0.000000 초

병합 정렬이 포함 된 패킹 된 목록 : 0.000000 초

N = 100000 :

병합 정렬이있는 조각난 목록 : 0.039000 초

qsort가있는 어레이 : 0.025000 초

병합 정렬이 포함 된 패킹 된 목록 : 0.009000 초

N = 1000000 :

병합 정렬이있는 조각난 목록 : 1.162000 초

qsort가있는 어레이 : 0.420000 초

병합 정렬이 포함 된 패킹 된 목록 : 0.112000 초

N = 100000000 :

병합 정렬이있는 조각난 목록 : 364.797000 초

qsort가있는 어레이 : 61.166000 초

병합 정렬이있는 압축 목록 : 16.525000 초

결론:

적어도 내 컴퓨터에서는 배열로 복사하는 것이 캐시 성능을 향상시키는 데 그만한 가치가 있습니다. 실생활에서 완전히 묶인 연결 목록이 거의 없기 때문입니다. 내 컴퓨터에는 2.8GHz Phenom II가 있지만 0.6GHz RAM 만 있으므로 캐시가 매우 중요합니다.


2
좋은 의견이지만, 목록에서 배열로 데이터를 복사하는 비용 (목록을 순회해야 함)과 퀵 정렬을위한 최악의 실행 시간을 고려해야합니다.
csl

1
O (n * log n)은 이론적으로 복사 비용을 포함하는 O (n * log n + n)과 동일합니다. 충분히 큰 n의 경우 복사 비용은 실제로 중요하지 않습니다. 목록을 한 번 끝까지 순회하는 것은 n 번이어야합니다.
Dean J

1
@DeanJ : 이론적으로는 그렇습니다.하지만 원본 포스터는 마이크로 최적화가 중요한 경우를 제시하고 있다는 것을 기억하십시오. 그리고이 경우 연결 목록을 배열로 전환하는 데 소요되는 시간을 고려해야합니다. 의견은 통찰력이 있지만 실제로 성능 향상을 제공 할 것이라고 완전히 확신하지는 않습니다. 아마도 매우 작은 N에 대해 작동 할 수 있습니다.
csl

1
@csl : 사실, 나는 큰 N에 대해 지역성의 이점이 시작될 것으로 기대합니다. + N log (N) 의 작은 부분이 될 qsort의 미스 수를 더합니다 (qsort의 대부분의 액세스는 최근 액세스 한 요소에 가까운 요소에 대한 것이므로). 병합 정렬의 실패 횟수는 N log (N) 의 더 큰 부분입니다 . 비교 비율이 높으면 캐시 누락이 발생하기 때문입니다. 따라서 큰 N의 경우이 용어가 병합 정렬을 지배하고 느려집니다.
Steve Jessop

2
@Steve : qsort가 드롭 인 대체물이 아니라는 말이 맞습니다.하지만 내 요점은 qsort 대 mergesort에 관한 것이 아닙니다. qsort를 쉽게 사용할 수있을 때 다른 버전의 mergesort를 작성하고 싶지 않았습니다. 표준 라이브러리는 방법으로 자신의 압연보다 편리합니다.
Jørgen Fogh

8

비교 정렬 (즉, 비교 요소를 기반으로하는 정렬)은보다 빠를 수 없습니다 n log n. 기본 데이터 구조가 무엇인지는 중요하지 않습니다. Wikipedia를 참조하십시오 .

목록에 동일한 요소가 많이 있음 (카운팅 정렬 등)을 활용하는 다른 종류의 정렬 또는 목록에서 예상되는 요소 분포가 더 빠릅니다. 특히 잘 작동하는 항목은 생각할 수 없습니다. 연결 목록에.



5

여러 번 언급했듯이 일반 데이터에 대한 비교 기반 정렬의 하한은 O (n log n)입니다. 이러한 인수를 간단히 다시 요약하기 위해 n! 목록을 정렬 할 수있는 다양한 방법. n이있는 모든 종류의 비교 트리! (O (n ^ n)에 있음) 가능한 최종 정렬은 높이로 최소한 log (n!)이 필요합니다. 이것은 O (log (n ^ n)) 하한을 제공합니다. 즉, O (n lg n).

따라서 연결된 목록의 일반 데이터의 경우 두 개체를 비교할 수있는 모든 데이터에 대해 작동 할 수있는 최상의 정렬은 O (n log n)입니다. 그러나 작업 할 영역이 더 제한적이라면 소요 시간을 개선 할 수 있습니다 (적어도 n에 비례). 예를 들어, 어떤 값보다 크지 않은 정수로 작업하는 경우 Counting Sort 또는 Radix Sort를 사용할 수 있습니다. 이러한 개체는 정렬중인 특정 개체를 사용하여 n에 비례하여 복잡성을 줄이기 때문입니다. 하지만주의해야 할 점은 고려하지 않을 수있는 복잡성에 몇 가지 다른 사항을 추가합니다 (예를 들어 Counting Sort 및 Radix 정렬은 모두 정렬하는 숫자의 크기를 기반으로하는 요소를 추가합니다. O (n + k ) 여기서 k는 Counting Sort에서 가장 큰 숫자의 크기입니다).

또한 완벽한 해시 (또는 적어도 모든 값을 다르게 매핑하는 해시)를 가진 객체가있는 경우 해당 해시 함수에 대해 계수 또는 기수 정렬을 사용해 볼 수 있습니다.


3

기수 정렬 특히는 한 디지트의 각각의 가능한 값에 대응하는 헤드 포인터 테이블을 쉽게 만들 수 있으므로, 링크 된리스트에 적합하다.


1
이 주제에 대해 자세히 설명하거나 링크드리스트에서 기수 정렬에 대한 리소스 링크를 제공 할 수 있습니까?
LoveToCode

2

병합 정렬에는 O (1) 액세스가 필요하지 않으며 O (n ln n)입니다. 일반 데이터를 정렬하는 알려진 알고리즘이 O (n ln n)보다 낫습니다.

기수 정렬 (데이터 크기 제한) 또는 히스토그램 정렬 (개별 데이터 계산)과 같은 특수 데이터 알고리즘은 O (1) 액세스가있는 다른 구조를 임시 저장소로 사용하는 한 낮은 성장 함수로 연결된 목록을 정렬 할 수 있습니다. .

특수 데이터의 또 다른 클래스는 k 요소가 순서가 맞지 않는 거의 정렬 된 목록의 비교입니다. 이것은 O (kn) 연산으로 정렬 할 수 있습니다.

목록을 배열에 복사하고 다시 복사하는 것은 O (N)이므로 공간이 문제가되지 않는 경우 모든 정렬 알고리즘을 사용할 수 있습니다.

예를 들어를 포함하는 연결 목록이 주어지면 uint_8이 코드는 히스토그램 정렬을 사용하여 O (N) 시간으로 정렬합니다.

#include <stdio.h>
#include <stdint.h>
#include <malloc.h>

typedef struct _list list_t;
struct _list {
    uint8_t value;
    list_t  *next;
};


list_t* sort_list ( list_t* list )
{
    list_t* heads[257] = {0};
    list_t* tails[257] = {0};

    // O(N) loop
    for ( list_t* it = list; it != 0; it = it -> next ) {
        list_t* next = it -> next;

        if ( heads[ it -> value ] == 0 ) {
            heads[ it -> value ] = it;
        } else {
            tails[ it -> value ] -> next = it;
        }

        tails[ it -> value ] = it;
    }

    list_t* result = 0;

    // constant time loop
    for ( size_t i = 255; i-- > 0; ) {
        if ( tails[i] ) {
            tails[i] -> next = result;
            result = heads[i];
        }
    }

    return result;
}

list_t* make_list ( char* string )
{
    list_t head;

    for ( list_t* it = &head; *string; it = it -> next, ++string ) {
        it -> next = malloc ( sizeof ( list_t ) );
        it -> next -> value = ( uint8_t ) * string;
        it -> next -> next = 0;
    }

    return head.next;
}

void free_list ( list_t* list )
{
    for ( list_t* it = list; it != 0; ) {
        list_t* next = it -> next;
        free ( it );
        it = next;
    }
}

void print_list ( list_t* list )
{
    printf ( "[ " );

    if ( list ) {
        printf ( "%c", list -> value );

        for ( list_t* it = list -> next; it != 0; it = it -> next )
            printf ( ", %c", it -> value );
    }

    printf ( " ]\n" );
}


int main ( int nargs, char** args )
{
    list_t* list = make_list ( nargs > 1 ? args[1] : "wibble" );


    print_list ( list );

    list_t* sorted = sort_list ( list );


    print_list ( sorted );

    free_list ( list );
}

5
n log n보다 빠른 비교 기반 정렬 알고리즘이 없다는 것이 입증 되었습니다 .
Artelius

9
아니, 더 비교 기반의 정렬 알고리즘을 입증 됐어요 일반 데이터에가 빠른 N 로그 n보다 없습니다
피트 Kirkham

아니요, O(n lg n)비교 기반이 아닌 것보다 빠른 정렬 알고리즘 (예 : 기수 정렬). 정의에 따라 비교 정렬은 총 순서가있는 모든 도메인에 적용됩니다 (즉, 비교할 수 있음).
bdonlan

3
@bdonlan "일반 데이터"의 요점은 무작위 입력이 아닌 제한된 입력에 대해 더 빠른 알고리즘이 있다는 것입니다. 제한적인 경우에는 입력 데이터가 이미 정렬되도록 제한되어있는 목록을 정렬하는 간단한 O (1) 알고리즘을 작성할 수 있습니다.
Pete Kirkham

그리고 그것은 비교 기반 정렬이 아닙니다. 비교 정렬이 이미 일반 데이터를 처리하기 때문에 "일반 데이터에 대한"수정자는 중복됩니다 (그리고 big-O 표기법은 비교 횟수에 대한 것임).
Steve Jessop

1

질문에 대한 직접적인 대답은 아니지만 Skip List 를 사용하는 경우 이미 정렬되어 있고 O (log N) 검색 시간이 있습니다.


1
예상 O(lg N) 검색 시간-건너 뛰기 목록은 임의성에 의존하므로 보장되지는 않습니다. 신뢰할 수없는 입력을 수신하는 경우, 입력의 공급이 RNG를 예측할 수 있는지, 또는 그들은 그것의 최악의 성능을 트리거 당신에게 데이터를 보낼 수
bdonlan

1

내가 아는 바와 같이, 가장 좋은 정렬 알고리즘은 O (n * log n)입니다. 컨테이너가 무엇이든간에-단어의 넓은 의미 (병합 / 빠른 정렬 등)의 정렬이 더 낮아질 수 없다는 것이 입증되었습니다. 연결된 목록을 사용하면 더 나은 실행 시간을 얻을 수 없습니다.

O (n)에서 실행되는 유일한 알고리즘은 실제로 정렬하는 대신 값을 계산하는 "해킹"알고리즘입니다.


3
해킹 알고리즘이 아니며 O (n)에서 실행되지 않습니다. O (cn)에서 실행됩니다. 여기서 c는 정렬하는 가장 큰 값 (실제로는 가장 높은 값과 가장 낮은 값의 차이입니다)이며 정수 값에서만 작동합니다. O (n)과 O (cn) 사이에는 차이가 있습니다. 정렬하는 값에 대해 명확한 상한을 제공하지 않는 한 (따라서 상수로 제한하지 않는 한) 복잡성을 복잡하게 만드는 두 가지 요소가 있습니다.
DivineWolfwood

엄밀히 말하면 O(n lg c). 모든 요소가 고유 한 경우 c >= n이므로보다 오래 걸립니다 O(n lg n).
bdonlan

1

다음 은 목록을 한 번만 탐색하고 실행을 수집 한 다음 mergesort와 동일한 방식으로 병합을 예약 하는 구현 입니다.

복잡성은 O (n log m)입니다. 여기서 n은 항목 수이고 m은 실행 수입니다. 최상의 경우는 O (n) (데이터가 이미 정렬 된 경우)이고 최악의 경우는 O (n log n)입니다.

O (log m) 임시 메모리가 필요합니다. 정렬은 목록에서 제자리에서 수행됩니다.

(아래 업데이트 됨. 댓글 작성자는 여기에 설명해야한다는 점을 지적합니다)

알고리즘의 요점은 다음과 같습니다.

    while list not empty
        accumulate a run from the start of the list
        merge the run with a stack of merges that simulate mergesort's recursion
    merge all remaining items on the stack

달리기 누적은 많은 설명이 필요하지 않지만 상승하는 달리기와 내림 차기 (반전)를 모두 축적 할 수있는 기회를 잡는 것이 좋습니다. 여기서는 실행 헤드보다 작은 항목을 앞에 추가하고 실행 종료보다 크거나 같은 항목을 추가합니다. (prepending은 정렬 안정성을 유지하기 위해 strict less-than을 사용해야합니다.)

여기에 병합 코드를 붙여 넣는 것이 가장 쉽습니다.

    int i = 0;
    for ( ; i < stack.size(); ++i) {
        if (!stack[i])
            break;
        run = merge(run, stack[i], comp);
        stack[i] = nullptr;
    }
    if (i < stack.size()) {
        stack[i] = run;
    } else {
        stack.push_back(run);
    }

목록 (dagibecfjh) 정렬을 고려하십시오 (실행 무시). 스택 상태는 다음과 같이 진행됩니다.

    [ ]
    [ (d) ]
    [ () (a d) ]
    [ (g), (a d) ]
    [ () () (a d g i) ]
    [ (b) () (a d g i) ]
    [ () (b e) (a d g i) ]
    [ (c) (b e) (a d g i ) ]
    [ () () () (a b c d e f g i) ]
    [ (j) () () (a b c d e f g i) ]
    [ () (h j) () (a b c d e f g i) ]

그런 다음 마지막으로이 모든 목록을 병합합니다.

stack [i]의 항목 (실행) 수는 0 또는 2 ^ i이고 스택 크기는 1 + log2 (nruns)로 제한됩니다. 각 요소는 스택 수준 당 한 번씩 병합되므로 O (n log m) 비교가 수행됩니다. Timsort는 2의 거듭 제곱을 사용하는 피보나치 수열과 같은 것을 사용하여 스택을 유지하지만 여기에 Timsort와 유사한 점이 있습니다.

누적 실행은 이미 정렬 된 데이터를 활용하므로 이미 정렬 된 목록 (한 번 실행)에 대한 최상의 경우 복잡성은 O (n)입니다. 오름차순 실행과 내림차순 실행을 모두 누적하고 있으므로 실행은 항상 최소 길이 2입니다. (이렇게하면 실행을 찾는 비용을 지불하면서 최대 스택 깊이가 최소 1 개 감소합니다.) 최악의 경우 복잡성은 다음과 같습니다. O (n log n), 예상대로 고도로 무작위 화 된 데이터의 경우.

(음 ... 두 번째 업데이트입니다.)

또는 상향식 병합 정렬 에 대한 위키피디아를 참조하십시오 .


런 생성이 "역 입력"으로 잘 수행되는 것은 좋은 터치입니다. O(log m)추가 메모리가 필요하지 않습니다. 하나가 비워 질 때까지 두 목록에 번갈아 실행을 추가하면됩니다.
수염이 희끗 희끗 한

1

배열에 복사 한 다음 정렬 할 수 있습니다.

  • 배열 O (n)에 복사,

  • 정렬 O (nlgn) (병합 정렬과 같은 빠른 알고리즘을 사용하는 경우),

  • 필요한 경우 연결 목록 O (n)에 다시 복사

그래서 그것은 O (nlgn)가 될 것입니다.

연결된 목록의 요소 수를 모르는 경우 배열의 크기를 알 수 없습니다. Java로 코딩하는 경우 예를 들어 Arraylist를 사용할 수 있습니다.


이것은 Jørgen Fogh의 대답에 무엇을 추가 합니까?
greybeard


0

문제는 LeetCode # 148 이며 모든 주요 언어로 제공되는 많은 솔루션이 있습니다. 내 것은 다음과 같지만 시간의 복잡성이 궁금합니다. 중간 요소를 찾기 위해 매번 전체 목록을 탐색합니다. 첫 번째 n요소는 반복되고 두 번째 2 * n/2요소는 반복됩니다. O(n^2)시간 인 것 같습니다 .

def sort(linked_list: LinkedList[int]) -> LinkedList[int]:
    # Return n // 2 element
    def middle(head: LinkedList[int]) -> LinkedList[int]:
        if not head or not head.next:
            return head
        slow = head
        fast = head.next

        while fast and fast.next:
            slow = slow.next
            fast = fast.next.next

        return slow

    def merge(head1: LinkedList[int], head2: LinkedList[int]) -> LinkedList[int]:
        p1 = head1
        p2 = head2
        prev = head = None

        while p1 and p2:
            smaller = p1 if p1.val < p2.val else p2
            if not head:
                head = smaller
            if prev:
                prev.next = smaller
            prev = smaller

            if smaller == p1:
                p1 = p1.next
            else:
                p2 = p2.next

        if prev:
            prev.next = p1 or p2
        else:
            head = p1 or p2

        return head

    def merge_sort(head: LinkedList[int]) -> LinkedList[int]:
        if head and head.next:
            mid = middle(head)
            mid_next = mid.next
            # Makes it easier to stop
            mid.next = None

            return merge(merge_sort(head), merge_sort(mid_next))
        else:
            return head

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