Apa algoritme tercepat untuk mengurutkan daftar tertaut?


96

Saya ingin tahu apakah O (n log n) adalah yang terbaik yang dapat dilakukan daftar tertaut.


31
Asal tahu saja, O (nlogn) adalah batas untuk urutan berbasis perbandingan. Ada jenis berbasis non-perbandingan yang dapat memberikan kinerja O (n) (misalnya urutan penghitungan), tetapi mereka memerlukan batasan tambahan pada data.
MAK

Itu adalah hari-hari ketika pertanyaan tidak seperti "mengapa kode ini tidak berfungsi ?????" dapat diterima di SO.
Abhijit Sarkar

Jawaban:


100

Masuk akal untuk mengharapkan bahwa Anda tidak dapat melakukan yang lebih baik dari O (N log N) dalam waktu berjalan .

Namun, bagian yang menarik adalah menyelidiki apakah Anda dapat mengurutkannya di tempat , secara stabil , perilaku terburuknya, dan sebagainya.

Simon Tatham, dari Putty fame, menjelaskan cara mengurutkan daftar tertaut dengan jenis gabungan . Dia menyimpulkan dengan komentar berikut:

Seperti algoritme pengurutan harga diri lainnya, algoritme ini memiliki waktu berjalan O (N log N). Karena ini adalah Mergesort, waktu pengoperasian terburuk masih O (N log N); tidak ada kasus patologis.

Persyaratan penyimpanan tambahan kecil dan konstan (yaitu beberapa variabel dalam rutinitas penyortiran). Berkat perilaku yang berbeda secara inheren dari daftar tertaut dari larik, implementasi Mergesort ini menghindari biaya penyimpanan tambahan O (N) yang biasanya dikaitkan dengan algoritme.

Ada juga contoh implementasi di C yang berfungsi untuk daftar tertaut tunggal dan ganda.

Seperti yang disebutkan @ Jørgen Fogh di bawah ini, notasi big-O dapat menyembunyikan beberapa faktor konstan yang dapat menyebabkan satu algoritme bekerja lebih baik karena lokasi memori, karena jumlah item yang sedikit, dll.


3
Ini bukan untuk satu daftar tertaut. Kode C-nya menggunakan * prev dan * next.
LE

3
@LE Sebenarnya untuk keduanya . Jika Anda melihat tanda tangan untuk listsort, Anda akan melihat bahwa Anda dapat beralih menggunakan parameter int is_double.
csl

1
@LE: ini adalah versi Python dari listsortkode C yang hanya mendukung daftar tertaut tunggal
jfs

O (kn) secara teoritis linier, dan dapat dicapai dengan jenis ember. Dengan asumsi k yang wajar (jumlah bit / ukuran objek yang Anda sortir), mungkin akan sedikit lebih cepat
Adam

74

Bergantung pada sejumlah faktor, sebenarnya mungkin lebih cepat untuk menyalin daftar ke larik dan kemudian menggunakan Quicksort .

Alasan ini mungkin lebih cepat adalah karena array memiliki kinerja cache yang jauh lebih baik daripada daftar tertaut. Jika node dalam daftar tersebar di memori, Anda mungkin menghasilkan cache miss di semua tempat. Kemudian lagi, jika arraynya besar Anda akan mendapatkan cache yang hilang.

Mergesort paralel dengan lebih baik, jadi ini mungkin pilihan yang lebih baik jika itu yang Anda inginkan. Ini juga jauh lebih cepat jika Anda melakukannya langsung di daftar tertaut.

Karena kedua algoritme berjalan dalam O (n * log n), membuat keputusan yang tepat akan melibatkan pembuatan profil keduanya di mesin tempat Anda ingin menjalankannya.

--- EDIT

Saya memutuskan untuk menguji hipotesis saya dan menulis program C yang mengukur waktu (menggunakan clock()) yang dibutuhkan untuk mengurutkan daftar int yang ditautkan. Saya mencoba dengan daftar tertaut di mana setiap node dialokasikan malloc()dan daftar tertaut di mana node diletakkan secara linier dalam sebuah array, sehingga kinerja cache akan lebih baik. Saya membandingkan ini dengan qsort bawaan, yang mencakup menyalin semuanya dari daftar terfragmentasi ke array dan menyalin hasilnya kembali. Setiap algoritma dijalankan pada 10 set data yang sama dan hasilnya dirata-ratakan.

Inilah hasilnya:

N = 1000:

Daftar terpecah dengan jenis gabungan: 0,000000 detik

Array dengan qsort: 0,000000 detik

Daftar yang dikemas dengan jenis gabungan: 0,000000 detik

N = 100000:

Daftar terfragmentasi dengan jenis gabungan: 0,039000 detik

Array dengan qsort: 0,025000 detik

Daftar yang dikemas dengan jenis gabungan: 0,009000 detik

N = 1000000:

Daftar terfragmentasi dengan jenis gabungan: 1,162000 detik

Array dengan qsort: 0,420000 detik

Daftar yang dikemas dengan jenis gabungan: 0,112000 detik

N = 100000000:

Daftar terfragmentasi dengan jenis gabungan: 364.797000 detik

Array dengan qsort: 61.166000 detik

Daftar yang dikemas dengan jenis gabungan: 16,525000 detik

Kesimpulan:

Setidaknya di mesin saya, menyalin ke dalam array sangat bermanfaat untuk meningkatkan kinerja cache, karena Anda jarang memiliki daftar tertaut yang sepenuhnya dikemas dalam kehidupan nyata. Perlu dicatat bahwa mesin saya memiliki Phenom II 2.8GHz, tetapi hanya RAM 0.6GHz, jadi cache sangat penting.


2
Komentar yang bagus, tetapi Anda harus mempertimbangkan biaya tidak konstan untuk menyalin data dari daftar ke larik (Anda harus melintasi daftar), serta waktu berjalan kasus terburuk untuk quicksort.
csl

1
O (n * log n) secara teori sama dengan O (n * log n + n), yang akan termasuk biaya penyalinan. Untuk n yang cukup besar, biaya penyalinan seharusnya tidak menjadi masalah; melintasi daftar sekali sampai akhir harus n kali.
Dean J

1
@DeanJ: Secara teoritis, ya, tapi ingat bahwa poster asli mengemukakan kasus di mana pengoptimalan mikro penting. Dan dalam hal ini, waktu yang dihabiskan untuk mengubah daftar tertaut menjadi sebuah array harus dipertimbangkan. Komentarnya berwawasan, tapi saya tidak sepenuhnya yakin itu akan memberikan keuntungan kinerja dalam kenyataan. Mungkin berhasil untuk N yang sangat kecil, mungkin.
csl

1
@csl: Sebenarnya, saya mengharapkan manfaat dari lokalitas untuk memulai N. Dengan asumsi bahwa cache miss adalah efek kinerja yang dominan, maka pendekatan copy-qsort-copy menghasilkan sekitar 2 * N cache yang hilang untuk disalin, ditambah jumlah kesalahan untuk qsort, yang merupakan pecahan kecil dari N log (N) (karena sebagian besar akses dalam qsort adalah ke elemen yang dekat dengan elemen yang baru diakses). Jumlah kesalahan untuk jenis gabungan adalah bagian yang lebih besar dari N log (N), karena proporsi perbandingan yang lebih tinggi menyebabkan cache tidak ditemukan. Jadi untuk N besar, istilah ini mendominasi dan memperlambat mergesort.
Steve Jessop

2
@Steve: Anda benar bahwa qsort bukanlah pengganti drop-in, tetapi maksud saya bukan tentang qsort vs. mergesort. Saya hanya tidak ingin menulis versi lain dari mergesort ketika qsort sudah tersedia. Perpustakaan standar jauh lebih nyaman daripada menggulung milik Anda sendiri.
Jørgen Fogh

8

Jenis perbandingan (yaitu yang didasarkan pada elemen pembanding) tidak mungkin lebih cepat dari n log n. Tidak masalah apa struktur data yang mendasarinya. Lihat Wikipedia .

Jenis lain yang memanfaatkan banyaknya elemen identik dalam daftar (seperti jenis penghitungan), atau beberapa distribusi elemen yang diharapkan dalam daftar, lebih cepat, meskipun saya tidak dapat memikirkan yang berfungsi dengan baik pada daftar tertaut.



5

Seperti yang dinyatakan berkali-kali, batas bawah pengurutan berbasis perbandingan untuk data umum adalah O (n log n). Untuk meringkas argumen ini secara singkat, ada n! berbagai cara memilah daftar. Semua jenis pohon pembanding yang memiliki n! (yang ada di O (n ^ n)) kemungkinan pengurutan terakhir akan membutuhkan setidaknya log (n!) sebagai tingginya: ini memberi Anda batas bawah O (log (n ^ n)), yaitu O (n log n).

Jadi, untuk data umum pada daftar tertaut, pengurutan terbaik yang akan bekerja pada data yang dapat membandingkan dua objek adalah O (n log n). Namun, jika Anda memiliki domain yang lebih terbatas untuk mengerjakan sesuatu, Anda dapat meningkatkan waktu yang dibutuhkan (setidaknya sebanding dengan n). Misalnya, jika Anda bekerja dengan bilangan bulat yang tidak lebih besar dari beberapa nilai, Anda dapat menggunakan Counting Sort atau Radix Sort , karena ini menggunakan objek spesifik yang Anda sortir untuk mengurangi kompleksitas dengan proporsi ke n. Berhati-hatilah, ini menambahkan beberapa hal lain ke kompleksitas yang mungkin tidak Anda pertimbangkan (misalnya, Counting Sort dan Radix sort keduanya menambahkan faktor yang didasarkan pada ukuran angka yang Anda sortir, O (n + k) ) di mana k adalah ukuran bilangan terbesar untuk Counting Sort, misalnya).

Selain itu, jika Anda kebetulan memiliki objek yang memiliki hash sempurna (atau setidaknya hash yang memetakan semua nilai secara berbeda), Anda dapat mencoba menggunakan penghitungan atau pengurutan radix pada fungsi hashnya.


3

Sebuah Radix semacam ini sangat cocok untuk sebuah linked list, karena sangat mudah untuk membuat tabel pointer kepala sesuai untuk setiap nilai yang mungkin dari digit.


1
Bisakah Anda menjelaskan lebih lanjut tentang topik ini atau memberikan tautan sumber daya untuk jenis radix dalam daftar tertaut.
LoveToCode

2

Merge sort tidak memerlukan akses O (1) dan merupakan O (n ln n). Tidak ada algoritma yang dikenal untuk menyortir data umum yang lebih baik dari O (n ln n).

Algoritme data khusus seperti radix sort (batas ukuran data) atau histogram sort (menghitung data diskrit) dapat mengurutkan daftar tertaut dengan fungsi pertumbuhan yang lebih rendah, selama Anda menggunakan struktur yang berbeda dengan akses O (1) sebagai penyimpanan sementara .

Kelas lain dari data khusus adalah semacam perbandingan dari daftar yang hampir diurutkan dengan elemen k rusak. Ini dapat diurutkan dalam operasi O (kn).

Menyalin daftar ke larik dan kembali akan menjadi O (N), jadi algoritme pengurutan apa pun dapat digunakan jika spasi bukan masalah.

Misalnya, diberikan daftar tertaut yang berisi uint_8, kode ini akan mengurutkannya dalam waktu O (N) menggunakan semacam histogram:

#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
Telah dibuktikan bahwa tidak ada algoritma pengurutan berbasis perbandingan yang lebih cepat dari n log n.
Artelius

9
Tidak, telah terbukti bahwa tidak ada algoritme pengurutan berbasis perbandingan pada data umum yang lebih cepat daripada n log n
Pete Kirkham

Tidak, algoritme pengurutan apa pun yang lebih cepat daripada O(n lg n)tidak akan menjadi berbasis perbandingan (misalnya, pengurutan radix). Menurut definisi, jenis perbandingan berlaku untuk domain apa pun yang memiliki urutan total (yaitu, dapat dibandingkan).
bdonlan

3
@bdonlan Inti dari "data umum" adalah bahwa ada algoritma yang lebih cepat untuk input terbatas, daripada input acak. Pada kasus yang membatasi, Anda dapat menulis algoritme O (1) sepele yang mengurutkan daftar mengingat data input dibatasi untuk sudah diurutkan
Pete Kirkham

Dan itu bukan jenis berdasarkan perbandingan. Pengubah "pada data umum" adalah redundan, karena sortir perbandingan sudah menangani data umum (dan notasi big-O adalah untuk jumlah perbandingan yang dibuat).
Steve Jessop

1

Bukan jawaban langsung untuk pertanyaan Anda, tetapi jika Anda menggunakan Daftar Lewati , itu sudah diurutkan dan memiliki waktu pencarian O (log N).


1
O(lg N)waktu pencarian yang diharapkan - tetapi tidak dijamin, karena daftar lewati bergantung pada keacakan. Jika Anda menerima input yang tidak tepercaya, pastikan pemasok input tidak dapat memprediksi RNG Anda, atau mereka dapat mengirimi Anda data yang memicu kinerja kasus terburuknya
bdonlan

1

Seperti yang saya tahu, algoritme pengurutan terbaik adalah O (n * log n), apa pun wadahnya - telah terbukti bahwa pengurutan dalam arti luas kata (gaya mergesort / quicksort dll) tidak bisa lebih rendah. Menggunakan daftar tertaut tidak akan memberi Anda waktu pengoperasian yang lebih baik.

Satu-satunya algoritme yang berjalan di O (n) adalah algoritme "retas" yang mengandalkan nilai penghitungan alih-alih pengurutan.


3
Ini bukan algoritma hack, dan tidak berjalan di O (n). Ini berjalan di O (cn), di mana c adalah nilai terbesar yang Anda sortir (yah, sebenarnya itu adalah perbedaan antara nilai tertinggi dan terendah) dan hanya berfungsi pada nilai integral. Ada perbedaan antara O (n) dan O (cn), kecuali jika Anda dapat memberikan batas atas definitif untuk nilai yang Anda sortir (dan dengan demikian mengikatnya dengan konstanta), Anda memiliki dua faktor yang memperumit kompleksitas.
DivineWolfwood

Sebenarnya, itu masuk O(n lg c). Jika semua elemen Anda unik, maka c >= ndibutuhkan waktu lebih lama dari O(n lg n).
bdonlan

1

Berikut adalah implementasi yang melintasi daftar hanya sekali, mengumpulkan proses, lalu menjadwalkan penggabungan dengan cara yang sama seperti penggabungan.

Kompleksitas adalah O (n log m) dimana n adalah jumlah item dan m adalah jumlah run. Kasus terbaik adalah O (n) (jika data sudah diurutkan) dan kasus terburuk adalah O (n log n) seperti yang diharapkan.

Ini membutuhkan memori sementara O (log m); pengurutan dilakukan di tempat pada daftar.

(diperbarui di bawah. commenter one membuat poin yang bagus bahwa saya harus menjelaskannya di sini)

Inti dari algoritma ini adalah:

    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

Mengumpulkan run tidak membutuhkan banyak penjelasan, tetapi ada baiknya untuk mengambil kesempatan ini untuk mengakumulasi jalur ascending dan descending run (terbalik). Di sini ia menambahkan item yang lebih kecil dari kepala proses dan menambahkan item yang lebih besar dari atau sama dengan akhir proses. (Perhatikan bahwa prepending harus menggunakan tight less-than untuk menjaga stabilitas sortir.)

Paling mudah hanya menempelkan kode penggabungan di sini:

    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);
    }

Pertimbangkan untuk mengurutkan daftar (dagibecfjh) (mengabaikan proses). Status tumpukan dilanjutkan sebagai berikut:

    [ ]
    [ (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) ]

Kemudian, akhirnya, gabungkan semua daftar ini.

Perhatikan bahwa jumlah item (berjalan) pada tumpukan [i] adalah nol atau 2 ^ i dan ukuran tumpukan dibatasi oleh 1 + log2 (nruns). Setiap elemen digabung sekali per level tumpukan, karenanya O (n log m) perbandingan. Ada kemiripan lewat Timsort di sini, meskipun Timsort mempertahankan tumpukannya menggunakan sesuatu seperti deret Fibonacci di mana ini menggunakan pangkat dua.

Akumulasi proses memanfaatkan data yang telah diurutkan sehingga kompleksitas kasus terbaik adalah O (n) untuk daftar yang sudah diurutkan (satu proses). Karena kita mengumpulkan proses ascending dan descending, run akan selalu setidaknya panjang 2. (Ini mengurangi kedalaman tumpukan maksimum setidaknya satu, membayar biaya untuk menemukan proses di tempat pertama.) Kompleksitas kasus terburuk adalah O (n log n), seperti yang diharapkan, untuk data yang sangat diacak.

(Um ... Pembaruan kedua.)

Atau lihat saja wikipedia di mergesort bottom-up .


Setelah menjalankan pembuatan, bekerja dengan baik dengan "input terbalik" adalah sentuhan yang bagus. O(log m)memori tambahan tidak diperlukan - cukup tambahkan proses ke dua daftar secara bergantian sampai salah satu kosong.
greybeard

1

Anda dapat menyalinnya ke dalam array dan kemudian mengurutkannya.

  • Menyalin ke dalam larik O (n),

  • sorting O (nlgn) (jika Anda menggunakan algoritma cepat seperti merge sort),

  • menyalin kembali ke daftar tertaut O (n) jika perlu,

jadi itu akan menjadi O (nlgn).

Perhatikan bahwa jika Anda tidak mengetahui jumlah elemen dalam daftar tertaut, Anda tidak akan mengetahui ukuran larik. Jika Anda membuat kode di java, Anda dapat menggunakan Arraylist misalnya.


Apa yang ditambahkan ini atas jawaban Jørgen Fogh ?
greybeard


0

Pertanyaannya adalah LeetCode # 148 , dan ada banyak solusi yang ditawarkan dalam semua bahasa utama. Milik saya adalah sebagai berikut, tetapi saya bertanya-tanya tentang kerumitan waktu. Untuk menemukan elemen tengah, kami melintasi daftar lengkap setiap kali. nElemen pertama kali diiterasi, 2 * n/2elemen kedua diulangi, seterusnya, dan seterusnya. Sepertinya sudah O(n^2)waktunya.

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)
Dengan menggunakan situs kami, Anda mengakui telah membaca dan memahami Kebijakan Cookie dan Kebijakan Privasi kami.
Licensed under cc by-sa 3.0 with attribution required.