2 điểm bởi GN⁺ 2024-07-26 | 1 bình luận | Chia sẻ qua WhatsApp
  • Trung vị có thể được tìm dễ dàng bằng cách sắp xếp, nhưng do chi phí sắp xếp, ngay cả bài toán chọn một phần tử đơn lẻ cũng bị giới hạn ở O(n log n)
  • quickselect chỉ đệ quy tìm kiếm phía cần thiết, nên có thể tìm phần tử kth hoặc trung vị với thời gian trung bình O(n)
  • Pivot ngẫu nhiên hoạt động tốt trong thực tế, nhưng nếu liên tục chọn phải pivot xấu, mỗi lần chỉ loại bỏ được một phần tử và có thể suy giảm đến O(n²)
  • median-of-medians chọn lại trung vị từ các trung vị của những nhóm 5 phần tử, giúp loại bỏ ít nhất 30% số phần tử ngay cả trong trường hợp xấu nhất
  • Trong triển khai thực tế, chi phí tính pivot có thể lớn, nên introselect, kết hợp quickselect và heapselect như trong thư viện chuẩn C++, có thể là lựa chọn thực dụng hơn

Giới hạn khi tìm trung vị bằng cách sắp xếp

  • Cách đơn giản nhất để tính trung vị là sắp xếp danh sách rồi chọn giá trị ở chỉ số giữa
  • Với danh sách có độ dài lẻ, trả về phần tử ở giữa; với danh sách có độ dài chẵn, trả về trung bình của hai phần tử ở giữa
  • Độ phức tạp thời gian nhanh nhất của sắp xếp dựa trên so sánh là O(n log n), nên thời gian chạy của cách này cũng bị chi phối bởi việc sắp xếp
  • Ưu điểm là mã đơn giản, nhưng để chỉ tìm một trung vị thì nó làm nhiều việc hơn cần thiết

quickselect tạo ra O(n) trung bình

  • quickselect là thuật toán đệ quy do Tony Hoare tạo ra, có thể tìm không chỉ trung vị mà cả phần tử kth bất kỳ trong danh sách
  • Luồng cơ bản là chia danh sách theo pivot, rồi chỉ tiếp tục tìm kiếm phía chứa phần tử kth
    • Chọn một pivot từ danh sách
    • Chia danh sách thành các phần tử nhỏ hơn hoặc bằng pivot và các phần tử lớn hơn pivot
    • Xác định phần tử kth cần tìm nằm ở phía nào rồi chỉ gọi đệ quy trên danh sách con tương ứng
    • Khi đi xuống danh sách con bên phải, điều chỉnh giá trị k theo số phần tử bên trái đã bị loại trừ
  • Trong danh sách ví dụ [9,1,0,2,3,4,6,8,7,10,5], vì độ dài là 11 nên ta tìm phần tử nhỏ thứ 6; tùy theo pivot mà thu hẹp phạm vi và cuối cùng trả về 5
  • quickselect_median dùng quickselect để tìm một chỉ số giữa nếu độ dài danh sách là lẻ; nếu là chẵn thì tìm hai chỉ số giữa rồi lấy trung bình
  • Nếu pivot chia danh sách gần như thành hai nửa, lượng xử lý là n + n/2 + n/4 + ... = 2n, nên trở thành O(n)

Muốn tránh trường hợp xấu nhất thì cần pivot tốt

  • O(n) trung bình của quickselect dựa trên điều kiện rằng việc chọn pivot đủ tốt
  • Nếu không may, chẳng hạn mỗi bước đều chọn giá trị lớn nhất làm pivot, thì mỗi bước chỉ loại bỏ được một phần tử và trở thành O(n²)
  • Để đảm bảo thời gian tuyến tính ngay cả trong trường hợp xấu nhất, cần cung cấp cho quickselect một pivot đủ tốt trong thời gian tuyến tính
  • Thuật toán chọn pivot này được Blum, Floyd, Pratt, Rivest và Tarjan phát triển năm 1973; bài báo liên quan được liên kết tại 1973 paper

Chọn pivot bằng median-of-medians

  • median-of-medians là quy trình chọn pivot tốt để quickselect sử dụng
  • Luồng triển khai như sau
    • Nếu có ít hơn 5 phần tử, dùng hàm tìm trung vị dựa trên sắp xếp hiện có
    • Chia danh sách thành các nhóm 5 phần tử
    • Để đơn giản, bỏ các nhóm không đủ 5 phần tử
    • Sắp xếp từng nhóm và thu thập trung vị ở chỉ số 2
    • Tìm lại trung vị trong danh sách các trung vị đã thu thập và trả về làm pivot
  • Vì kích thước mỗi nhóm được cố định là 5, việc sắp xếp theo nhóm được coi là thời gian hằng số, và toàn bộ là công việc O(n)
  • Lời gọi đệ quy để tìm trung vị của các trung vị được đưa vào phân tích như một bài toán con có kích thước n/5

Vì sao có thể loại bỏ ít nhất 30%

  • Có thể phân tích chất lượng của pivot bằng cách tưởng tượng rằng ta sắp xếp các nhóm 5 phần tử thành từng cột, rồi sắp xếp lại các trung vị của mỗi cột để chọn trung vị của các trung vị
  • Ngay cả trong trường hợp xấu nhất khi pivot lệch về phía trước nhiều nhất có thể, các phần tử ở một số phần tư nhất định vẫn được đảm bảo nhỏ hơn hoặc lớn hơn pivot
  • Nếu lấy 3 phần tử ở mỗi cột và xét một nửa số cột, ta có thể loại bỏ ít nhất 3/5 * 1/2 * n = 3/10 n phần tử
  • Tỷ lệ loại bỏ được đảm bảo: {p:30}
  • Tổng thời gian chạy được biểu diễn bằng hệ thức truy hồi sau
T(n) = n + T(n/5) + T(7n/10)
  • Ở đây, n tương ứng với công việc phân hoạch, T(n/5) là tính median-of-medians, còn T(7n/10) là tìm kiếm đệ quy của quickselect
  • Vì hệ thức truy hồi này có hai hạng đệ quy, không thể áp dụng định lý Master đơn giản; chứng minh bằng quy nạp là cách trực quan hơn

Kết quả kết hợp: tìm trung vị trong thời gian tuyến tính

  • quickselect có thể tìm trung vị trong thời gian tuyến tính nếu được cung cấp pivot đủ tốt
  • median-of-medians có thể chọn pivot tốt cần thiết cho quickselect trong O(n)
  • Kết hợp hai thuật toán này sẽ tạo thành thuật toán tìm trung vị hoặc phần tử thứ n của danh sách trong thời gian tuyến tính

Lựa chọn trong triển khai thực tế

  • Trong thực tế, chọn pivot ngẫu nhiên hầu như luôn là đủ
  • median-of-medians cũng có thời gian tuyến tính, nhưng trên thực tế có thể chậm vì chi phí tính pivot lớn
  • Thư viện chuẩn C++ sử dụng introselect, kết hợp heapselect và quickselect, với cận trên O(n log n)
  • introselect thường bắt đầu bằng một thuật toán nhanh nhưng có cận trên xấu, rồi chuyển sang thuật toán chậm hơn nhưng có cận trên tốt khi không chọn được pivot hiệu quả
  • Trong so sánh số phần tử mà hàm quickselect xem xét, pivot tất định hầu như luôn xét ít phần tử hơn pivot ngẫu nhiên, nhưng so sánh này không bao gồm chi phí tính median-of-medians
  • new paper xuất bản năm 2017 trình bày cách khiến hướng tiếp cận median-of-medians có thể cạnh tranh với các thuật toán chọn khác

1 bình luận

 
GN⁺ 2024-07-26
Các ý kiến trên Hacker News
  • Khoảng 4 năm trước, tôi đã so sánh nhiều thuật toán tìm trung vị, và bài viết dài hơn dự kiến rất nhiều :)
    https://danlark.org/2020/11/11/miniselect-practical-and-generic-selection-algorithms/

    • Trong số này có thuật toán nào có thể dễ dàng sửa thành arg-median, tức là trả về chỉ số chứa giá trị trung vị không?
  • Cách đây 10–15 năm, tôi thường xuyên phải tìm trung vị của hàng tỷ giá trị được parse từ các mục log dài nhiều kilobyte. Khi đó chúng tôi dùng MapReduce để xử lý dữ liệu lớn, và với lượng dữ liệu như vậy, không chỉ cần thời gian tuyến tính mà còn cần một cách có thể phân tán trên nhiều máy trong một lượt duyệt, nếu có thể
    Việc biết trước độ chính xác và phạm vi của dữ liệu rất hữu ích. Các giá trị là thời gian tính bằng mili giây nguyên nên không âm, và chúng tôi cũng biết phân vị thứ 90 nhỏ hơn 1 giây rất nhiều
    Thông thường việc tìm trung vị đòi hỏi thao tác tương tự sắp xếp, nhưng với các điều kiện này thì có thể dùng bucket sort. Chỉ cần tạo một dictionary, tức histogram, với key là thời gian mili giây nguyên và value là số lần xuất hiện
    Vì không biết thời gian tối đa, để kích thước dictionary không phình to, mọi giá trị vượt quá 999ms đều được đưa vào bucket 999ms; như vậy chỉ bị giới hạn ở khoảng 2000 số nguyên gồm các key 0–999 và các value. Điểm này khác với bucket sort thông thường, và ngay cả khi phân tán bằng MapReduce cũng có thể xử lý rất dễ trong một lượt duyệt, rồi sau đó lấy trung vị từ histogram

    • Thực sự có cần trung vị chính xác của hàng tỷ giá trị không? Hay chỉ cần một giá trị nằm giữa 49,9% và 50,1% là đủ? Nếu là trường hợp sau thì dễ hơn nhiều. Lấy mẫu ngẫu nhiên đều 10.000 giá trị rồi dùng trung vị của chúng là được
      Con số 10.000 chỉ là ví dụ tùy ý, nhưng số mẫu cần cho mức tin cậy mong muốn có thể tính bằng thống kê, và tôi nghĩ nó sẽ không quá lớn
    • Tôi không chắc, nhưng nhìn từ bên ngoài thì có vẻ khá giống cách Prometheus làm bên trong
      Trong một số hệ thống tôi từng xử lý, Prometheus hoạt động như thể đặt giới hạn khoảng 10 giây cho độ trễ. Vì vậy các request vượt quá giới hạn đó đều được ghi nhận là 10 giây, dù thực tế có thể lâu hơn. Khá thú vị
    • Có phải anh đang làm chỉ số khả dụng, và lúc đó là thực tập sinh không? Hệ thống đó nghe, ừm, rất quen
    • Không hiểu vì sao lại dùng dictionary với key 0…999. Dùng mảng được đánh chỉ số 0…999 không phải là được sao?
  • Có phần tái bút nói rằng năm 2017 đã có một bài báo mới khiến cách tiếp cận median-of-medians có thể cạnh tranh với các thuật toán selection khác, và tác giả bài báo, Andrei Alexandrescu, đã cho biết điều đó
    Ông ấy cũng có một bài trình bày về thuật toán của mình vào năm 2016. Ông là người thuyết trình thú vị, nên tôi rất khuyến nghị
    There's Treasure Everywhere - Andrei Alexandrescu
    https://www.youtube.com/watch?v=fd1_Miy1Clg

    • Andrei Alexandrescu thật xuất sắc. Khoảng năm 2000, ông ấy đã trình bày về các thuật toán không khóa (lock-free), không chờ (wait-free), và tôi đã áp dụng ngay vào một dự án mạng điều khiển công nghiệp C++ khổng lồ lúc đó
      Nếu bạn là người dùng phần mềm, tôi khuyên nên nghe và đọc tất cả các bài viết, bài nói chuyện của Andrei mà bạn có thể tìm được. Bài trình bày này cũng thật sự là một kho báu
    • Ngạc nhiên là ông ấy là một người khá uyên bác ngay cả theo tiêu chuẩn khoa học máy tính. Tôi biết đến ông qua template metaprogramming, nhưng ở đây ông đang chuyển từ ngôn ngữ lập trình sang mảng thuật toán
  • Hồi đại học tôi đã học thuật toán median-of-medians quickselect và thấy thật sự ấn tượng. Tôi đã thử tự triển khai, nhưng nó chậm kinh khủng. Thời gian chạy tăng tuyến tính, nhưng để điều đó có ý nghĩa thì danh sách phải có ít nhất hàng chục tỷ phần tử
    Khi tôi nói chuyện này với một người bạn học cao học, anh ấy nói đại ý rằng: “Nó chậm thật, nhưng điều quan trọng là nó chứng minh rằng có thể selection trên một danh sách chưa sắp xếp trong thời gian O(n). Từng có lúc người ta không biết liệu điều đó có khả thi hay không, và giờ ta biết là có thể, nên cũng có thể tồn tại các thuật toán tuyến tính nhanh hơn”
    Đó là một bài học vừa quá đơn giản vừa sâu sắc, đến mức tôi suýt nộp đơn vào cao học. Tôi không biết người bạn đó có còn nhớ cuộc trò chuyện này không, nhưng với việc học của tôi thì đó là một khoảnh khắc mang tính bước ngoặt

    • Việc tồn tại một thuật toán thời gian tuyến tính có gợi ý rằng cũng tồn tại một thuật toán thời gian tuyến tính nhanh hơn không? Nếu không thì lợi ích của kiến thức đó là gì?
      Cũng có thể nghĩ rằng “vì ta đã biết có một thuật toán nào đó tồn tại, nên cũng có thể có thuật toán nhanh hơn”. Vì sao sự tồn tại của thuật toán O(n) lại là tín hiệu mạnh hơn sự tồn tại của thuật toán O(n log n)?
    • Tôi nghĩ mình đã học thuật toán này vào năm 4 ngành khoa học máy tính. Như đã nói, nó cũng được dùng để bàn về khía cạnh lý thuyết, nhưng còn là ví dụ cho thấy trong hầu hết trường hợp thực tế, một thuật toán tuyến tính chậm không nhanh hơn một thuật toán n log n nhanh
      Tôi nhớ hệ số hằng số của thuật toán này khoảng 22, nhưng cũng có thể đó là một thuật toán liên quan
  • Một điểm thú vị của thuật toán median-of-medians là danh sách tác giả hoàn toàn thuộc hàng toàn sao
    Manuel Blum - người nhận giải Turing năm 1995
    Robert Floyd - người nhận giải Turing năm 1978
    Ron Rivest - người nhận giải Turing năm 2002
    Bob Tarjan - người nhận giải Turing năm 1986 và là người đầu tiên nhận giải Nevanlinna năm 1982
    Vaughan Pratt - người duy nhất trong danh sách không nhận giải Turing, nhưng là giáo sư danh dự Stanford, từng dẫn dắt dự án SUN trước khi nó trở thành Sun Microsystems, đóng vai trò quan trọng thời kỳ đầu của Sun với tư cách trưởng bộ phận nghiên cứu và nhà thiết kế logo Sun, đồng thời để lại nhiều thứ thú vị như chứng chỉ tính nguyên tố Pratt
    Bốn giải Turing độc lập cộng thêm cả SPARCstation nữa, bài báo này có đủ

    • Câu hỏi phỏng vấn lập trình viên frontend mới vào nghề: “Trong 30 phút tới, hãy tái hiện công trình của bốn người nhận giải Turing. Bạn có một cái bảng trắng bẩn và một cây bút đã khô. Thời gian bắt đầu từ bây giờ”
    • Liên kết trực tiếp cho ai muốn đọc bài báo gốc: https://people.csail.mit.edu/rivest/pubs/BFPRT73.pdf
      Danh sách tác giả đúng là ấn tượng
    • Một công trình thú vị khác của Pratt là Pratt parsing. Thảo luận trên HN: https://news.ycombinator.com/item?id=39066465
      Chữ “P” trong thuật toán KMP cũng là Pratt
  • return l[len(l) / 2]
    Tôi không phải chuyên gia Python, nhưng trong Python toán tử / chẳng phải trả về số thực dấu phẩy động sao? Tại sao không dùng phép chia nguyên // thay vì dùng số thực làm chỉ số mảng?
    Nếu mảng không quá lớn thì có thể không thành vấn đề, nhưng vẫn có mùi code smell khá rõ. Nếu là người mới học Python nên không biết có hai toán tử riêng thì còn có thể bỏ qua, nhưng trong bài còn có đoạn lạ hơn: một nhánh dùng chia nguyên, nhánh khác dùng chia số thực
    def quickselect_median(l, pivot_fn=random.choice):
    if len(l) % 2 == 1:
    return quickselect(l, len(l) // 2, pivot_fn)
    else:
    return 0.5 * (quickselect(l, len(l) / 2 - 1, pivot_fn) +
    quickselect(l, len(l) / 2, pivot_fn))
    Đã có tới 50 bình luận mà dường như không ai thấy chuyện này, chỉ càng củng cố định kiến sẵn có của tôi về chất lượng code Python trung bình

    • Bắt lỗi hay đấy. Python 2 chỉ có một toán tử, nhưng Python 3 thì phân biệt hai cái
      Tôi nghĩ dùng số thực để index mảng sẽ gây exception
    • Tôi đồng ý là có mùi code smell. Tuy vậy đây là bài viết về thuật toán, nên đánh giá bằng chất lượng code thì không hẳn công bằng
      Tác giả đã chọn một ngôn ngữ lập trình thật trông giống giả mã thay vì dùng giả mã, và để minh họa thì có lẽ đoạn code chạy được ổn
  • Tôi đọc bài gốc rất thích, nhưng đoạn “nếu ở mỗi bước chọn phần tử lớn nhất làm pivot thì có thể thành O(n²) thay vì O(n)” khiến tôi hơi vướng
    Nếu lo đầu vào mang tính đối kháng, có thể shuffle dữ liệu trước trong O(n) để ngăn chuyện đó bị cưỡng ép xảy ra. Nếu dữ liệu quá lớn nên khó shuffle, thì chỉ cần trộn một lần khi bucket đã nhỏ tới kích thước có thể shuffle được
    Nếu đã shuffle, xác suất đảm bảo trường hợp xấu nhất thực tế sẽ không xảy ra. Nếu ai đó nói “về mặt kỹ thuật” vẫn có thể, tôi sẽ đáp rằng “về mặt kỹ thuật” kẻ tấn công cũng có thể đoán đúng mọi bit của khóa riêng 256-bit
    Thế giới của chúng ta được xây trên xác suất. Mọi khóa riêng đều được bảo vệ bởi sự bất khả về mặt toán học rằng không ai có thể đoán chính xác
    Theo cách tôi hiểu, quickselect sau khi shuffle trên thực tế là O(n)

    • Vì đã tự dùng số ngẫu nhiên để chọn pivot ngẫu nhiên rồi, tôi không hiểu shuffle giúp thêm gì
      Dù vậy, nếu có thể tin cậy nguồn ngẫu nhiên thì xác suất thời gian chạy vượt quá O(n) là rất thấp
    • “Nếu lo đầu vào mang tính đối kháng thì shuffle dữ liệu trước trong O(n) sẽ đảm bảo điều đó” không phải là đảm bảo tránh trường hợp xấu nhất, mà là loại bỏ khả năng ép xảy ra trường hợp xấu nhất
  • Floyd-Rivest cũng làm được việc đó. Nếu tôi nhớ không nhầm thì còn hiệu quả hơn một chút
    Nhưng tôi rốt cuộc vẫn không hiểu được nguyên lý hoạt động của nó
    https://en.m.wikipedia.org/wiki/Floyd%E2%80%93Rivest_algorithm

  • Khi chọn phần tử thứ n, nếu n rất nhỏ hoặc rất lớn thì median-of-medians có thể không phải lựa chọn tốt nhất
    Thay vào đó có thể dùng pivot lệch như [1], hoặc cách mà tôi gọi là “phần tử thứ j trong k”. Floyd-Rivest cũng có thể tăng tốc
    Tôi có một dự án sở thích đạt thông lượng 1,2~2,0 lần so với quickselect được triển khai tốt: https://github.com/koskinev/turboselect
    Nếu có tài liệu về thuật toán chọn tại chỗ tổng quát và nhanh, tôi rất quan tâm
    [1] https://doi.org/10.4230/LIPIcs.SEA.2017.24

  • Cũng có thể dùng thuật toán streaming để tính gần đúng các phân vị tùy ý mà không cần lưu toàn bộ dữ liệu trong bộ nhớ

    • Nếu có thể chấp nhận giá trị gần đúng thì đây là một cách rất hay. Nhưng rồi sẽ sớm xuất hiện những câu hỏi khó chịu
      Có thể cho phép tính gần đúng không? Cần giả định gì về dữ liệu để đặt giới hạn sai số? Làm sao kiểm chứng rằng các giả định đó vẫn còn đúng?
      Cá nhân tôi có lẽ sẽ nghiêng về thuật toán quickselect trong bài gốc cho tới khi rơi vào tình huống buộc phải cân nhắc cách xấp xỉ trung vị kiểu streaming
    • Chắc chắn đã có những tình huống mà thuật toán phân vị streaming sẽ hữu ích. Có tài liệu nào đáng tham khảo không?