Tôi nghĩ rằng có một số câu hỏi chôn trong chủ đề này:
- Làm thế nào để bạn thực hiện
buildHeap
để nó chạy trong thời gian O (n) ?
- Làm thế nào để bạn chỉ ra rằng
buildHeap
chạy trong thời gian O (n) khi được thực hiện chính xác?
- Tại sao logic tương tự không hoạt động để làm cho heap sort chạy trong thời gian O (n) chứ không phải O (n log n) ?
Làm thế nào để bạn thực hiện buildHeap
để nó chạy trong thời gian O (n) ?
Thông thường, câu trả lời cho những câu hỏi này tập trung vào sự khác biệt giữa siftUp
và siftDown
. Việc lựa chọn chính xác giữa siftUp
và siftDown
rất quan trọng để có được hiệu suất O (n)buildHeap
, nhưng không giúp được gì cho người ta hiểu sự khác biệt giữa buildHeap
và heapSort
nói chung. Thật vậy, thực hiện đúng cả hai buildHeap
và heapSort
sẽ chỉ sử dụng siftDown
. Các siftUp
hoạt động chỉ là cần thiết để thực hiện chèn vào một đống hiện, vì vậy nó sẽ được sử dụng để thực hiện một hàng đợi ưu tiên sử dụng một đống nhị phân, ví dụ.
Tôi đã viết điều này để mô tả cách hoạt động của một heap tối đa. Đây là loại heap thường được sử dụng để sắp xếp heap hoặc cho hàng đợi ưu tiên trong đó các giá trị cao hơn biểu thị mức độ ưu tiên cao hơn. Một đống tối thiểu cũng hữu ích; ví dụ: khi truy xuất các mục có khóa số nguyên theo thứ tự tăng dần hoặc chuỗi theo thứ tự bảng chữ cái. Các nguyên tắc hoàn toàn giống nhau; chỉ cần chuyển đổi thứ tự sắp xếp.
Các bất động sản đống quy định cụ thể mà mỗi nút trong một đống nhị phân ít nhất phải lớn như cả hai con của nó. Đặc biệt, điều này ngụ ý rằng mục lớn nhất trong heap là ở gốc. Sift xuống và sàng lên về cơ bản là cùng một hoạt động theo hướng ngược lại: di chuyển một nút vi phạm cho đến khi nó thỏa mãn thuộc tính heap:
siftDown
hoán đổi một nút quá nhỏ với con lớn nhất của nó (do đó di chuyển nó xuống) cho đến khi nó lớn nhất bằng cả hai nút bên dưới nó.
siftUp
hoán đổi một nút quá lớn với cha của nó (do đó di chuyển nó lên) cho đến khi nó không lớn hơn nút phía trên nó.
Số lượng thao tác cần thiết siftDown
và siftUp
tỷ lệ thuận với khoảng cách mà nút có thể phải di chuyển. Đối với siftDown
, đó là khoảng cách đến dưới cùng của cây, vì vậy rất siftDown
tốn kém cho các nút ở đầu cây. Với siftUp
, công việc tỷ lệ thuận với khoảng cách đến ngọn cây, vì vậy rất siftUp
tốn kém cho các nút ở dưới cùng của cây. Mặc dù cả hai hoạt động là O (log n) trong trường hợp xấu nhất, trong một đống, chỉ có một nút ở trên cùng trong khi một nửa các nút nằm ở lớp dưới cùng. Vì vậy, không quá ngạc nhiên khi chúng ta phải áp dụng một thao tác cho mọi nút, chúng ta sẽ thích siftDown
hơn siftUp
.
Các buildHeap
chức năng phải mất một mảng các mặt hàng không được phân loại và di chuyển chúng cho đến khi tất cả họ đều thoả mãn tính chất heap, do đó tạo ra một đống hợp lệ. Có hai cách tiếp cận người ta có thể thực hiện khi buildHeap
sử dụng siftUp
và các siftDown
thao tác chúng tôi đã mô tả.
Bắt đầu ở đầu heap (phần đầu của mảng) và gọi siftUp
từng mục. Ở mỗi bước, các mục đã sàng trước đó (các mục trước mục hiện tại trong mảng) tạo thành một đống hợp lệ và sàng các mục tiếp theo lên đặt nó vào một vị trí hợp lệ trong heap. Sau khi chọn từng nút, tất cả các mục thỏa mãn thuộc tính heap.
Hoặc, đi theo hướng ngược lại: bắt đầu ở cuối mảng và di chuyển về phía trước. Ở mỗi lần lặp, bạn chọn một mục xuống cho đến khi nó ở đúng vị trí.
Việc thực hiện nào cho buildHeap
hiệu quả hơn?
Cả hai giải pháp này sẽ tạo ra một đống hợp lệ. Không có gì đáng ngạc nhiên, hoạt động hiệu quả hơn là hoạt động thứ hai sử dụng siftDown
.
Đặt h = log n đại diện cho chiều cao của heap. Công việc cần thiết cho siftDown
cách tiếp cận được tính bằng tổng
(0 * n/2) + (1 * n/4) + (2 * n/8) + ... + (h * 1).
Mỗi thuật ngữ trong tổng có khoảng cách tối đa mà một nút ở độ cao đã cho sẽ phải di chuyển (0 cho lớp dưới cùng, h cho gốc) nhân với số lượng nút ở độ cao đó. Ngược lại, tổng số để gọi siftUp
trên mỗi nút là
(h * n/2) + ((h-1) * n/4) + ((h-2)*n/8) + ... + (0 * 1).
Cần phải rõ ràng rằng tổng thứ hai là lớn hơn. Chỉ riêng thuật ngữ đầu tiên là hn / 2 = 1/2 n log n , vì vậy cách tiếp cận này có độ phức tạp cao nhất là O (n log n) .
Làm thế nào để chúng tôi chứng minh tổng cho siftDown
cách tiếp cận thực sự là O (n) ?
Một phương pháp (có những phân tích khác cũng hoạt động) là biến tổng hữu hạn thành một chuỗi vô hạn và sau đó sử dụng chuỗi Taylor. Chúng tôi có thể bỏ qua thuật ngữ đầu tiên, đó là số không:
Nếu bạn không chắc chắn tại sao mỗi bước đó hoạt động, đây là lời biện minh cho quy trình bằng từ ngữ:
- Các thuật ngữ đều dương, vì vậy tổng hữu hạn phải nhỏ hơn tổng vô hạn.
- Sê-ri bằng với chuỗi lũy thừa được đánh giá ở x = 1/2 .
- Chuỗi lũy thừa đó bằng (một thời gian không đổi) đạo hàm của chuỗi Taylor cho f (x) = 1 / (1-x) .
- x = 1/2 nằm trong khoảng hội tụ của chuỗi Taylor đó.
- Do đó, chúng ta có thể thay thế chuỗi Taylor bằng 1 / (1-x) , phân biệt và đánh giá để tìm giá trị của chuỗi vô hạn.
Vì tổng vô hạn chính xác là n , nên chúng tôi kết luận rằng tổng hữu hạn không lớn hơn và do đó, O (n) .
Tại sao sắp xếp heap yêu cầu thời gian O (n log n) ?
Nếu có thể chạy buildHeap
trong thời gian tuyến tính, tại sao sắp xếp heap yêu cầu thời gian O (n log n) ? Vâng, sắp xếp heap bao gồm hai giai đoạn. Đầu tiên, chúng ta gọi buildHeap
vào mảng, đòi hỏi thời gian O (n) nếu được thực hiện tối ưu. Giai đoạn tiếp theo là liên tục xóa mục lớn nhất trong heap và đặt nó ở cuối mảng. Bởi vì chúng tôi xóa một mục khỏi heap, luôn có một vị trí mở ngay sau khi kết thúc heap nơi chúng tôi có thể lưu trữ mục đó. Vì vậy, sắp xếp heap đạt được một thứ tự được sắp xếp bằng cách loại bỏ liên tiếp mục lớn nhất tiếp theo và đưa nó vào mảng bắt đầu ở vị trí cuối cùng và di chuyển về phía trước. Đó là sự phức tạp của phần cuối cùng này chiếm ưu thế trong sắp xếp đống. Vòng lặp trông như thế này:
for (i = n - 1; i > 0; i--) {
arr[i] = deleteMax();
}
Rõ ràng, vòng lặp chạy O (n) lần ( n - 1 để chính xác, mục cuối cùng đã được đặt đúng chỗ). Độ phức tạp của deleteMax
một heap là O (log n) . Nó thường được thực hiện bằng cách loại bỏ gốc (mục lớn nhất còn lại trong heap) và thay thế nó bằng mục cuối cùng trong heap, đó là một chiếc lá, và do đó là một trong những mục nhỏ nhất. Root mới này gần như chắc chắn sẽ vi phạm thuộc tính heap, vì vậy bạn phải gọi siftDown
cho đến khi bạn chuyển nó trở lại vị trí chấp nhận được. Điều này cũng có tác dụng di chuyển vật phẩm lớn nhất tiếp theo lên đến gốc. Lưu ý rằng, trái ngược với buildHeap
nơi mà hầu hết các nút mà chúng ta đang gọi siftDown
từ dưới cùng của cây, chúng ta hiện đang gọi siftDown
từ đỉnh cây trên mỗi lần lặp!Mặc dù cây đang co lại, nhưng nó không co lại đủ nhanh : Chiều cao của cây không đổi cho đến khi bạn loại bỏ nửa nút đầu tiên (khi bạn xóa hoàn toàn lớp dưới cùng). Sau đó cho quý tiếp theo, chiều cao là h - 1 . Vì vậy, tổng công việc cho giai đoạn thứ hai này là
h*n/2 + (h-1)*n/4 + ... + 0 * 1.
Lưu ý công tắc: bây giờ trường hợp công việc bằng 0 tương ứng với một nút và trường hợp công việc h tương ứng với một nửa các nút. Tổng này là O (n log n) giống như phiên bản không hiệu quả buildHeap
được triển khai bằng siftUp. Nhưng trong trường hợp này, chúng tôi không có lựa chọn nào vì chúng tôi đang cố gắng sắp xếp và chúng tôi yêu cầu mục lớn nhất tiếp theo sẽ bị xóa tiếp theo.
Tóm lại, công việc sắp xếp heap là tổng của hai giai đoạn: O (n) thời gian cho buildHeap và O (n log n) để loại bỏ từng nút theo thứ tự , vì vậy độ phức tạp là O (n log n) . Bạn có thể chứng minh (sử dụng một số ý tưởng từ lý thuyết thông tin) rằng đối với loại sắp xếp dựa trên so sánh, O (n log n) là cách tốt nhất bạn có thể hy vọng dù sao đi nữa, vì vậy không có lý do gì để thất vọng về điều này hoặc mong đợi sắp xếp đống để đạt được O (n) ràng buộc thời gian mà buildHeap
.