Thuật toán sắp xếp danh sách liên kết nhanh nhất là gì?


95

Tôi tò mò nếu O (n log n) là tốt nhất mà một danh sách liên kết có thể làm.


31
Bạn biết đấy, O (nlogn) là giới hạn cho các loại dựa trên so sánh. Có những cách sắp xếp không dựa trên so sánh có thể mang lại hiệu suất O (n) (ví dụ: sắp xếp đếm), nhưng chúng yêu cầu các ràng buộc bổ sung về dữ liệu.
MAK

Đó là những ngày mà những câu hỏi không giống như "tại sao mã này không hoạt động ?????" được chấp nhận trên SO.
Abhijit Sarkar

Câu trả lời:


100

Có thể kỳ vọng rằng bạn không thể làm tốt hơn O (N log N) trong thời gian chạy .

Tuy nhiên, phần thú vị là điều tra xem bạn có thể sắp xếp nó đúng vị trí , ổn định , hành vi trong trường hợp xấu nhất của nó hay không, v.v.

Simon Tatham, người nổi tiếng Putty, giải thích cách sắp xếp một danh sách được liên kết với sắp xếp hợp nhất . Ông kết luận bằng những nhận xét sau:

Giống như bất kỳ thuật toán sắp xếp tự trọng nào, điều này có thời gian chạy O (N log N). Bởi vì đây là Mergesort, thời gian chạy trong trường hợp xấu nhất vẫn là O (N log N); không có trường hợp bệnh lý.

Yêu cầu lưu trữ phụ trợ là nhỏ và không đổi (tức là một vài biến trong quy trình sắp xếp). Nhờ hành vi vốn có khác nhau của danh sách được liên kết từ mảng, việc triển khai Hợp nhất này tránh được chi phí lưu trữ phụ trợ O (N) thường được liên kết với thuật toán.

Ngoài ra còn có một ví dụ triển khai trong C hoạt động cho cả danh sách liên kết đơn và kép.

Như @ Jørgen Fogh đề cập bên dưới, ký hiệu big-O có thể ẩn một số yếu tố không đổi có thể khiến một thuật toán hoạt động tốt hơn vì vị trí bộ nhớ, vì số lượng mục thấp, v.v.


3
Đây không phải là danh sách liên kết đơn lẻ. Mã C của anh ấy đang sử dụng * trước và * tiếp theo.
LE

3
@LE Nó thực sự cho cả hai . Nếu bạn thấy chữ ký cho listsort, bạn sẽ thấy bạn có thể chuyển đổi bằng cách sử dụng tham số int is_double.
csl

1
@LE: đây là phiên bản Python của listsortmã C chỉ hỗ trợ các danh sách được liên kết đơn lẻ
jfs

Về mặt lý thuyết, O (kn) là tuyến tính và có thể đạt được bằng cách sắp xếp theo nhóm. Giả sử k hợp lý (số bit / kích thước của đối tượng bạn đang sắp xếp), nó có thể nhanh hơn một chút
Adam

74

Tùy thuộc vào một số yếu tố, việc sao chép danh sách vào một mảng và sau đó sử dụng Quicksort thực sự có thể nhanh hơn .

Lý do điều này có thể nhanh hơn là một mảng có hiệu suất bộ nhớ cache tốt hơn nhiều so với một danh sách được liên kết. Nếu các nút trong danh sách bị phân tán trong bộ nhớ, bạn có thể tạo ra các lỗi bộ nhớ cache ở khắp nơi. Sau đó, một lần nữa, nếu mảng lớn, bạn sẽ bị thiếu bộ nhớ cache.

Hợp nhất các song song tốt hơn, vì vậy nó có thể là một lựa chọn tốt hơn nếu đó là những gì bạn muốn. Nó cũng nhanh hơn nhiều nếu bạn thực hiện nó trực tiếp trên danh sách liên kết.

Vì cả hai thuật toán đều chạy trong O (n * log n), nên việc đưa ra quyết định sáng suốt sẽ liên quan đến việc lập hồ sơ cả hai thuật toán trên máy mà bạn muốn chạy chúng.

--- BIÊN TẬP

Tôi quyết định kiểm tra giả thuyết của mình và viết một chương trình C đo thời gian (sử dụng clock()) để sắp xếp một danh sách liên kết các int. Tôi đã thử với một danh sách được liên kết trong đó mỗi nút được cấp phát malloc()và một danh sách được liên kết trong đó các nút được bố trí tuyến tính trong một mảng, vì vậy hiệu suất bộ nhớ cache sẽ tốt hơn. Tôi đã so sánh những thứ này với qsort tích hợp sẵn, bao gồm sao chép mọi thứ từ danh sách bị phân mảnh sang một mảng và sao chép lại kết quả. Mỗi thuật toán được chạy trên 10 tập dữ liệu giống nhau và kết quả được tính trung bình.

Đây là những kết quả:

N = 1000:

Danh sách bị phân mảnh với sắp xếp hợp nhất: 0,000000 giây

Mảng có qsort: 0,000000 giây

Danh sách được đóng gói với sắp xếp hợp nhất: 0,000000 giây

N = 100000:

Danh sách bị phân mảnh với sắp xếp hợp nhất: 0,039000 giây

Mảng có qsort: 0,025000 giây

Danh sách được đóng gói với sắp xếp hợp nhất: 0,009000 giây

N = 1000000:

Danh sách bị phân mảnh với sắp xếp hợp nhất: 1.162000 giây

Mảng với qsort: 0,420000 giây

Danh sách được đóng gói với sắp xếp hợp nhất: 0,112000 giây

N = 100000000:

Danh sách bị phân mảnh với sắp xếp hợp nhất: 364,797000 giây

Mảng với qsort: 61.166000 giây

Danh sách được đóng gói với sắp xếp hợp nhất: 16.525000 giây

Phần kết luận:

Ít nhất là trên máy tính của tôi, sao chép vào một mảng rất đáng để cải thiện hiệu suất bộ nhớ cache, vì bạn hiếm khi có một danh sách liên kết được đóng gói hoàn chỉnh trong cuộc sống thực. Cần lưu ý rằng máy của tôi có Phenom II 2,8GHz nhưng RAM chỉ có 0,6GHz nên bộ nhớ đệm rất quan trọng.


2
Nhận xét tốt, nhưng bạn nên xem xét chi phí không đổi của việc sao chép dữ liệu từ danh sách sang mảng (bạn phải duyệt qua danh sách), cũng như trường hợp xấu nhất là thời gian chạy nhanh nhất.
csl

1
O (n * log n) về mặt lý thuyết giống với O (n * log n + n), sẽ bao gồm chi phí của bản sao. Đối với bất kỳ n đủ lớn nào, chi phí của bản sao thực sự không thành vấn đề; duyệt qua danh sách một lần đến cuối phải là n lần.
Dean J

1
@DeanJ: Về mặt lý thuyết, có, nhưng hãy nhớ rằng người đăng ban đầu đang đưa ra trường hợp tối ưu hóa vi mô quan trọng. Và trong trường hợp đó, thời gian dành cho việc chuyển một danh sách được liên kết thành một mảng phải được xem xét. Các nhận xét rất sâu sắc, nhưng tôi không hoàn toàn tin rằng nó sẽ cung cấp hiệu suất tăng trong thực tế. Nó có thể hoạt động với một N rất nhỏ, có lẽ.
csl

1
@csl: Trên thực tế, tôi mong đợi những lợi ích của cục bộ sẽ mang lại cho N. Giả sử rằng bộ nhớ cache bị bỏ sót là hiệu ứng hiệu suất vượt trội, thì cách tiếp cận sao chép-qsort-sao chép dẫn đến khoảng 2 * N bộ nhớ cache bỏ lỡ việc sao chép, cộng với số lần bỏ lỡ cho qsort, sẽ là một phần nhỏ của N log (N) (vì hầu hết các truy cập trong qsort là đến một phần tử gần với phần tử được truy cập gần đây). Số lần bỏ sót cho sắp xếp hợp nhất là một phần lớn hơn của N log (N), vì tỷ lệ so sánh cao hơn gây ra lỗi bộ nhớ cache. Vì vậy, đối với N lớn, thuật ngữ này chiếm ưu thế và làm chậm quá trình hợp nhất.
Steve Jessop

2
@Steve: Bạn nói đúng rằng qsort không phải là một sự thay thế thả xuống, nhưng quan điểm của tôi không thực sự là về qsort so với mergesort. Tôi chỉ cảm thấy không muốn viết một phiên bản khác của hợp nhất khi qsort đã có sẵn. Thư viện tiêu chuẩn là cách thuận tiện hơn so với tự cuộn của bạn.
Jørgen Fogh

8

Các loại so sánh (tức là các loại dựa trên việc so sánh các phần tử) không thể nhanh hơn n log n. Không quan trọng cấu trúc dữ liệu cơ bản là gì. Xem Wikipedia .

Các kiểu sắp xếp khác tận dụng lợi thế của việc có nhiều phần tử giống nhau trong danh sách (chẳng hạn như kiểu đếm) hoặc một số phân phối dự kiến ​​của các phần tử trong danh sách, nhanh hơn, mặc dù tôi không thể nghĩ ra cách nào hoạt động đặc biệt tốt trên một danh sách được liên kết.


8

Đây là một bài báo nhỏ tốt đẹp về chủ đề này. Kết luận thực nghiệm của ông là Treesort là tốt nhất, tiếp theo là Quicksort và Mergesort. Phân loại trầm tích, phân loại bong bóng, phân loại lựa chọn hoạt động rất tệ.

NGHIÊN CỨU SO SÁNH CÁC THUẬT TOÁN SẮP XẾP DANH SÁCH LIÊN KẾT của Ching-Kuang Shene

http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.31.9981


5

Như đã nêu nhiều lần, giới hạn dưới của việc sắp xếp dựa trên so sánh cho dữ liệu chung sẽ là O (n log n). Tóm tắt lại một cách ngắn gọn những lập luận này, có n! các cách khác nhau một danh sách có thể được sắp xếp. Bất kỳ loại cây so sánh nào có n! (nằm trong O (n ^ n)) sắp xếp cuối cùng có thể sẽ cần ít nhất log (n!) làm chiều cao của nó: điều này cho bạn giới hạn dưới O (log (n ^ n)), là O (n log n).

Vì vậy, đối với dữ liệu chung trên danh sách liên kết, cách sắp xếp tốt nhất có thể sẽ hoạt động trên bất kỳ dữ liệu nào có thể so sánh hai đối tượng sẽ là O (n log n). Tuy nhiên, nếu bạn có phạm vi công việc hạn chế hơn, bạn có thể cải thiện thời gian thực hiện (ít nhất là tỷ lệ thuận với n). Ví dụ: nếu bạn đang làm việc với các số nguyên không lớn hơn một giá trị nào đó, bạn có thể sử dụng Sắp xếp đếm hoặc Sắp xếp theo cơ số , vì chúng sử dụng các đối tượng cụ thể mà bạn đang sắp xếp để giảm độ phức tạp theo tỷ lệ n. Tuy nhiên, hãy cẩn thận, những thứ này thêm một số thứ khác vào độ phức tạp mà bạn có thể không xem xét (ví dụ: Sắp xếp đếm và Sắp xếp theo cơ số đều thêm vào các yếu tố dựa trên kích thước của các số bạn đang sắp xếp, O (n + k ) trong đó k là kích thước của số lớn nhất cho Sắp xếp Đếm chẳng hạn).

Ngoài ra, nếu bạn tình cờ có các đối tượng có một hàm băm hoàn hảo (hoặc ít nhất là một hàm băm ánh xạ tất cả các giá trị khác nhau), bạn có thể thử sử dụng sắp xếp đếm hoặc cơ số trên các hàm băm của chúng.


3

Một Radix sort đặc biệt phù hợp với một danh sách liên kết, vì nó dễ dàng để tạo ra một bảng gợi ý đầu tương ứng với mỗi giá trị có thể có của một chữ số.


1
Bạn có thể vui lòng giải thích thêm về chủ đề này hoặc cung cấp bất kỳ liên kết tài nguyên nào để sắp xếp cơ số trong danh sách liên kết.
LoveToCode

2

Sắp xếp hợp nhất không yêu cầu quyền truy cập O (1) và là O (n ln n). Không có thuật toán nào được biết đến để sắp xếp dữ liệu chung tốt hơn O (n ln n).

Các thuật toán dữ liệu đặc biệt như sắp xếp cơ số (kích thước giới hạn của dữ liệu) hoặc sắp xếp biểu đồ (đếm dữ liệu rời rạc) có thể sắp xếp danh sách được liên kết có hàm tăng trưởng thấp hơn, miễn là bạn sử dụng cấu trúc khác với quyền truy cập O (1) làm bộ nhớ tạm thời .

Một lớp dữ liệu đặc biệt khác là một loại so sánh của một danh sách gần như được sắp xếp với k phần tử không theo thứ tự. Điều này có thể được sắp xếp trong các phép toán O (kn).

Sao chép danh sách vào một mảng và ngược lại sẽ là O (N), vì vậy, bất kỳ thuật toán sắp xếp nào cũng có thể được sử dụng nếu không gian không phải là vấn đề.

Ví dụ: cho một danh sách được liên kết có chứa uint_8, mã này sẽ sắp xếp nó theo thời gian O (N) bằng cách sử dụng sắp xếp biểu đồ:

#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
Người ta đã chứng minh rằng không tồn tại thuật toán sắp xếp dựa trên so sánh nào nhanh hơn n log n.
Artelius

9
Không, nó được chứng minh rằng không có thuật toán loại so sánh dựa trên số liệu chung là nhanh hơn so với log n n
Pete Kirkham

Không, bất kỳ thuật toán sắp xếp nào nhanh hơn O(n lg n)sẽ không dựa trên so sánh (ví dụ: sắp xếp cơ số). Theo định nghĩa, sắp xếp so sánh áp dụng cho bất kỳ miền nào có tổng thứ tự (tức là có thể được so sánh).
bdonlan

3
@bdonlan điểm của "dữ liệu chung" là có các thuật toán nhanh hơn cho đầu vào bị ràng buộc, thay vì đầu vào ngẫu nhiên. Tại trường hợp giới hạn, bạn có thể viết một O tầm thường (1) thuật toán mà sắp xếp một danh sách cho các dữ liệu đầu vào bị hạn chế được đã được sắp xếp
Pete Kirkham

Và đó sẽ không phải là một loại dựa trên so sánh. Công cụ sửa đổi "trên dữ liệu chung" là dư thừa, vì các loại so sánh đã xử lý dữ liệu chung (và ký hiệu big-O dành cho số lượng so sánh được thực hiện).
Steve Jessop 14/11/09

1

Không phải là câu trả lời trực tiếp cho câu hỏi của bạn, nhưng nếu bạn sử dụng Danh sách bỏ qua , danh sách đó đã được sắp xếp và có thời gian tìm kiếm O (log N).


1
O(lg N)thời gian tìm kiếm dự kiến - nhưng không được đảm bảo, vì danh sách bỏ qua phụ thuộc vào tính ngẫu nhiên. Nếu bạn nhận được đầu vào không tin cậy, hãy chắc chắn các nhà cung cấp các đầu vào không thể dự đoán RNG của bạn, hoặc họ có thể gửi cho bạn dữ liệu mà gây nên hiệu suất trường hợp tồi tệ nhất
bdonlan

1

Như tôi biết, thuật toán sắp xếp tốt nhất là O (n * log n), bất kể vùng chứa nào - nó đã được chứng minh rằng sắp xếp theo nghĩa rộng của từ này (kiểu mergesort / quicksort, v.v.) không thể thấp hơn. Sử dụng danh sách liên kết sẽ không mang lại cho bạn thời gian chạy tốt hơn.

Thuật toán duy nhất chạy trong O (n) là thuật toán "hack" dựa vào việc đếm các giá trị thay vì thực sự sắp xếp.


3
Nó không phải là một thuật toán hack và nó không chạy trong O (n). Nó chạy trong O (cn), trong đó c là giá trị lớn nhất mà bạn đang sắp xếp (tốt, thực sự đó là sự khác biệt giữa giá trị cao nhất và thấp nhất) và chỉ hoạt động trên các giá trị tích phân. Có sự khác biệt giữa O (n) và O (cn), vì trừ khi bạn có thể đưa ra giới hạn trên xác định cho các giá trị bạn đang sắp xếp (và do đó ràng buộc nó bằng một hằng số), bạn có hai yếu tố làm phức tạp thêm.
DivineWolfwood

Nói chính xác, nó chạy vào O(n lg c). Nếu tất cả các phần tử của bạn là duy nhất, thì c >= n, và do đó sẽ mất nhiều thời gian hơn O(n lg n).
bdonlan

1

Đây là cách triển khai duyệt qua danh sách chỉ một lần, thu thập các lần chạy, sau đó lên lịch cho các lần hợp nhất theo cách tương tự như cách hợp nhất thực hiện.

Độ phức tạp là O (n log m) với n là số mục và m là số lần chạy. Trường hợp tốt nhất là O (n) (nếu dữ liệu đã được sắp xếp) và trường hợp xấu nhất là O (n log n) như mong đợi.

Nó yêu cầu bộ nhớ tạm thời O (log m); việc sắp xếp được thực hiện tại chỗ trên danh sách.

(được cập nhật bên dưới. một người bình luận đưa ra một điểm tốt mà tôi nên mô tả nó ở đây)

Ý chính của thuật toán là:

    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

Việc tích lũy số lần chạy không cần giải thích nhiều, nhưng thật tốt nếu bạn có cơ hội để tích lũy số lần chạy tăng dần và số lần chạy giảm dần (đảo ngược). Ở đây, nó thêm vào các mục nhỏ hơn phần đầu của cuộc chạy và thêm các mục lớn hơn hoặc bằng phần cuối của lần chạy. (Lưu ý rằng chi tiêu trước nên sử dụng ít hơn nghiêm ngặt để duy trì sự ổn định của sắp xếp.)

Dễ nhất là chỉ cần dán mã hợp nhất vào đây:

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

Xem xét sắp xếp danh sách (dagibecfjh) (bỏ qua các lần chạy). Các trạng thái ngăn xếp tiến hành như sau:

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

Sau đó, cuối cùng, hợp nhất tất cả các danh sách này.

Lưu ý rằng số lượng mục (chạy) tại ngăn xếp [i] bằng 0 hoặc 2 ^ i và kích thước ngăn xếp được giới hạn bởi 1 + log2 (nruns). Mỗi phần tử được hợp nhất một lần trên mỗi mức ngăn xếp, do đó so sánh O (n log m). Có một điểm tương đồng với Timsort ở đây, mặc dù Timsort duy trì ngăn xếp của nó bằng cách sử dụng một thứ gì đó giống như dãy Fibonacci trong đó điều này sử dụng lũy ​​thừa của hai.

Việc tích lũy các lần chạy tận dụng mọi dữ liệu đã được sắp xếp sao cho độ phức tạp của trường hợp tốt nhất là O (n) cho danh sách đã được sắp xếp (một lần chạy). Vì chúng tôi đang tích lũy cả số lần chạy tăng dần và giảm dần, các lần chạy sẽ luôn có độ dài ít nhất là 2. (Điều này làm giảm độ sâu ngăn xếp tối đa ít nhất một, trả cho chi phí tìm kiếm các lần chạy ngay từ đầu.) O (n log n), như mong đợi, đối với dữ liệu được ngẫu nhiên hóa cao.

(Ừm ... Bản cập nhật thứ hai.)

Hoặc chỉ cần xem wikipedia trên hợp nhất từ dưới lên .


Việc tạo chạy hoạt động tốt với "đầu vào đảo ngược" là một liên lạc tốt. O(log m)Không cần thêm bộ nhớ - chỉ cần thêm lần chạy luân phiên vào hai danh sách cho đến khi một danh sách trống.
greybeard

1

Bạn có thể sao chép nó vào một mảng và sau đó sắp xếp nó.

  • Sao chép vào mảng O (n),

  • sắp xếp O (nlgn) (nếu bạn sử dụng một thuật toán nhanh như sắp xếp hợp nhất),

  • sao chép trở lại danh sách liên kết O (n) nếu cần,

vì vậy nó sẽ là O (nlgn).

lưu ý rằng nếu bạn không biết số phần tử trong danh sách liên kết, bạn sẽ không biết kích thước của mảng. Nếu bạn đang viết mã trong java, bạn có thể sử dụng Arraylist chẳng hạn.


Điều này bổ sung thêm điều gì cho câu trả lời của Jørgen Fogh ?
greybeard


0

Câu hỏi là LeetCode # 148 , và có rất nhiều giải pháp được cung cấp bằng tất cả các ngôn ngữ chính. Của tôi như sau, nhưng tôi tự hỏi về độ phức tạp thời gian. Để tìm phần tử ở giữa, chúng tôi duyệt qua danh sách đầy đủ mỗi lần. Các nphần tử thời gian đầu được lặp lại, 2 * n/2các phần tử thời gian thứ hai được lặp đi lặp lại, v.v. Có vẻ như đã đến O(n^2)lúc.

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)
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.