Find Median from Data Stream
Thiết kế một cấu trúc có hai thao tác: addNum lưu một số nguyên, còn findMedian trả về trung vị của tất cả giá trị đã lưu đến thời điểm hiện tại. Nếu có số lượng giá trị lẻ, trung vị là giá trị ở giữa; nếu có số lượng giá trị chẵn, trung vị là trung bình của hai giá trị ở giữa.
Thao tác i là ops[i], trong đó values[i] chứa số cần lưu cho thao tác addNum và chứa 0 cho thao tác findMedian. Thực hiện các thao tác theo thứ tự trên một cấu trúc rỗng và trả về một chuỗi cho mỗi thao tác: "null" cho addNum, trung vị cho findMedian. Viết trung vị như "3", "2.5" hoặc "-0.5": không có dấu thập phân nếu là số nguyên, và số 0 phải viết là "0", không bao giờ viết "-0".
Hàm
- opsstring-array
- các phép toán theo thứ tự thực hiện
- valuesinteger-array
- số lượng cho mỗi addNum, 0 cho mỗi findMedian
- Trả vềstring-array
- một câu trả lời cho mỗi phép toán, dưới dạng văn bản
Ràng buộc
1 ≤ ops.length ≤ 3000values.length == ops.length- Mỗi
ops[i]làaddNumhoặcfindMedian. -105 ≤ values[i] ≤ 105đối vớiaddNum, vàvalues[i] == 0đối vớifindMedian.findMedianchỉ được gọi sau khi đã gọiaddNumít nhất một lần.
Ví dụ
- Đầu vào
- ops = ["addNum", "findMedian", "addNum", "findMedian", "addNum", "findMedian", "addNum", "findMedian"]values = [5, 0, 1, 0, 8, 0, 3, 0]
- Đầu ra
- ["null", "5", "null", "3", "null", "5", "null", "4"]
- Giải thích
- Với 1 và 5, trung vị là (1 + 5) / 2 = 3. Với 1, 5 và 8, trung vị là 5, còn với 1, 3, 5 và 8, trung vị là (3 + 5) / 2 = 4.
- Đầu vào
- ops = ["addNum", "addNum", "findMedian", "addNum", "addNum", "findMedian"]values = [-4, 3, 0, -2, -1, 0]
- Đầu ra
- ["null", "null", "-0.5", "null", "null", "-1.5"]
- Giải thích
- -4 và 3 có tổng là -1, nên trung vị là -0.5. Sau -2 và -1, các giá trị đã sắp xếp là -4, -2, -1, 3; cặp ở giữa có tổng là -3, nên trung vị là -1.5.
Câu hỏi mở rộng
Nếu mọi giá trị đều nằm trong khoảng từ 0 đến 100, bạn có thể làm thế nào để cả hai thao tác đều chạy trong thời gian hằng số?
Gợi ý
Mở từng gợi ý một. Mỗi gợi ý tiết lộ thêm một chút.
Sắp xếp mọi giá trị trong mỗi lần
findMedianlà đúng, nhưng lại lặp lại nhiều công việc. Trung vị chỉ cần xét phần tử ở giữa. Bạn thực sự cần tìm nhanh những giá trị nào?Chia các giá trị thành nửa dưới và nửa trên. Trung vị chỉ phụ thuộc vào giá trị lớn nhất ở nửa dưới và giá trị nhỏ nhất ở nửa trên; thứ tự bên trong mỗi nửa không quan trọng.
Giữ nửa dưới trong một đống cực đại và nửa trên trong một đống cực tiểu, sao cho nửa dưới chứa số giá trị bằng với nửa trên hoặc nhiều hơn một giá trị. Đưa mỗi giá trị mới vào đúng phía bằng cách so sánh với phần tử đầu của nửa dưới, sau đó chuyển một phần tử đầu sang phía bên kia nếu kích thước hai nửa chênh lệch.
Lời giải
Trung vị nằm giữa hai nửa dữ liệu, nên thứ tự bên trong mỗi nửa không quan trọng: chỉ giá trị lớn nhất của nửa dưới và giá trị nhỏ nhất của nửa trên mới quan trọng. Hai đống giữ chính xác hai giá trị đó ở trên cùng. Mỗi lần gọi addNum tốn O(log n), còn mỗi lần gọi findMedian đọc hai phần tử trên cùng trong O(1).
Sắp xếp trong mỗi lần gọi findMedian
Ý tưởng
Lưu mọi giá trị vào một danh sách (list). findMedian sắp xếp danh sách rồi đọc các chỉ số (total-1)/2 và total/2 bằng phép chia số nguyên: với số lượng lẻ, hai chỉ số giống nhau; với số lượng chẵn, chúng chỉ vào cặp giá trị ở giữa. Cộng hai giá trị rồi chia đôi tổng, bạn sẽ có trung vị trong cả hai trường hợp.
Chia đôi bằng số nguyên. Tổng chẵn chia hết. Tổng lẻ cho ra một nửa giá trị tuyệt đối của nó, theo sau là .5, với dấu của tổng, nên -3 thành -1.5.
Mỗi truy vấn đều sắp xếp lại danh sách. Với 1,500 lần thêm và một truy vấn sau mỗi lần, cần khoảng 10^7 phép so sánh, trong khi các đống (heap) chỉ cần vài chục nghìn bước.
Thuật toán
- Giữ một danh sách
values. - Với
addNum x, thêmxvào danh sách và ghi lại"null". - Với
findMedian, sắp xếpvaluesrồi thêmvalues[(total-1)/2]vàvalues[total/2]. - Chia đôi tổng dưới dạng văn bản bằng số nguyên rồi ghi lại.
def half_text(total):
# total / 2 as text, from integers only: 5 -> "2.5", -1 -> "-0.5", 6 -> "3"
if total % 2 == 0:
return str(total // 2)
sign = "-" if total < 0 else ""
return sign + str(abs(total) // 2) + ".5"
class MedianFinder:
def __init__(self):
self.values = []
def addNum(self, x):
self.values.append(x)
def findMedian(self):
self.values.sort()
total = len(self.values)
# For an odd count both indexes point at the one middle value.
return half_text(self.values[(total - 1) // 2] + self.values[total // 2])
def medianStreamOps(ops, values):
finder = MedianFinder()
result = []
for op, x in zip(ops, values):
if op == "addNum":
finder.addNum(x)
result.append("null")
else:
result.append(finder.findMedian())
return resultHai heap được chia tại trung vị
Ý tưởng
Chia các giá trị thành nửa dưới low và nửa trên high, sao cho mọi giá trị trong low đều nhỏ hơn hoặc bằng mọi giá trị trong high, và kích thước của low bằng high hoặc lớn hơn một phần tử. Trung vị là giá trị lớn nhất của low, hoặc nếu có số lượng phần tử chẵn thì là trung bình của giá trị này với giá trị nhỏ nhất của high. Đống cực đại cho low và đống cực tiểu cho high giúp các giá trị đó luôn ở trên cùng.
Thêm giá trị mới vào low nếu giá trị đó nhỏ hơn hoặc bằng giá trị trên cùng của low, nếu không thì thêm vào high. Khi kích thước hai nửa chênh lệch, chuyển một giá trị trên cùng sang nửa kia: đó là giá trị gần với nửa kia nhất, nên thứ tự vẫn được giữ nguyên. Thao tác thêm cần một vài thao tác với đống, O(log n), hoặc O(n) trong R, vì mỗi lần thay đổi R đều sao chép đống.
Sau bốn lần thêm của ví dụ đầu tiên, low chứa 3 và 1, còn high chứa 5 và 8.
Thuật toán
- Duy trì một đống tối đa
lowcho nửa dưới và một đống tối thiểuhighcho nửa trên. addNum x: đẩyxvàolownếulowđang trống hoặcxkhông lớn hơn phần tử trên cùng của nó, nếu không thì đẩy vàohigh.- Nếu
lowcó nhiều hơnhighhai giá trị, chuyển phần tử trên cùng củalowsanghigh. Nếuhighcó nhiều giá trị hơnlow, chuyển phần tử trên cùng củahighsanglow. findMedian: nếulowlớn hơn, ghi lại phần tử trên cùng của nó. Nếu không, cộng hai phần tử trên cùng rồi lấy một nửa tổng bằng phép chia số nguyên.
import heapq
def half_text(total):
# total / 2 as text, from integers only: 5 -> "2.5", -1 -> "-0.5", 6 -> "3"
if total % 2 == 0:
return str(total // 2)
sign = "-" if total < 0 else ""
return sign + str(abs(total) // 2) + ".5"
class MedianFinder:
def __init__(self):
self.low = [] # the smaller half; heapq is a min-heap, so it stores -x to keep the largest on top
self.high = [] # the larger half, smallest on top
def addNum(self, x):
if not self.low or x <= -self.low[0]:
heapq.heappush(self.low, -x)
else:
heapq.heappush(self.high, x)
# Rebalance: low holds as many values as high, or one more.
if len(self.low) > len(self.high) + 1:
heapq.heappush(self.high, -heapq.heappop(self.low))
elif len(self.high) > len(self.low):
heapq.heappush(self.low, -heapq.heappop(self.high))
def findMedian(self):
if len(self.low) > len(self.high):
return str(-self.low[0])
return half_text(-self.low[0] + self.high[0])
def medianStreamOps(ops, values):
finder = MedianFinder()
result = []
for op, x in zip(ops, values):
if op == "addNum":
finder.addNum(x)
result.append("null")
else:
result.append(finder.findMedian())
return result
Lỗi thường gặp và trường hợp biên
- Chọn heap dựa trên kích thước thay vì giá trị. Đưa phần tử vào heap nhỏ hơn mà không so sánh sẽ phá vỡ thứ tự giữa hai nửa, khiến phần tử trên cùng không còn là các giá trị ở giữa.
- Chia đôi bằng phép chia số nguyên thông thường. Trong C và Java,
-1 / 2bằng 0, nên nối thêm.5sẽ in ra 0.5 cho -0.5. Trong Python,-3 // 2bằng -2, nên in ra -2.5 cho -1.5. Trong một số ngôn ngữ, số thực được in ra là 3.0 hoặc 2.500000. - Lưu
lowdưới dạng số đối trong heap min rồi quên đổi lại dấu khi đọc phần tử trên cùng hoặc chuyển một giá trị sanghigh. - Viết
-0. Hai giá trị ở giữa là -1 và 1 có tổng bằng 0, và đáp án là"0".
Câu hỏi thường gặp3
Độ phức tạp thời gian của Find Median from Data Stream là bao nhiêu?
Với hai heap, addNum có độ phức tạp O(log n), nhiều nhất ba thao tác đẩy hoặc lấy phần tử khỏi heap, còn findMedian có độ phức tạp O(1), với hai lần đọc. Mọi giá trị đều được lưu lại, nên độ phức tạp về bộ nhớ là O(n). Thay vào đó, sắp xếp mỗi lần truy vấn sẽ tốn O(n log n) cho mỗi lần gọi findMedian.
Tại sao không giữ các giá trị trong một mảng đã sắp xếp?
Một mảng đã sắp xếp giúp tìm giá trị trả về của findMedian trong O(1), còn tìm kiếm nhị phân xác định vị trí chèn giá trị mới trong O(log n). Nhưng khi chèn, mọi giá trị phía sau vị trí đó đều bị dịch chuyển, mất O(n) cho mỗi lần gọi addNum.
Bạn có thể tìm trung vị chính xác của một luồng dữ liệu mà không cần lưu mọi giá trị không?
Không phải lúc nào cũng vậy. Bất kỳ giá trị nào đã thấy đều có thể trở thành giá trị trung vị sau này, tùy thuộc vào những gì xuất hiện tiếp theo, vì vậy cần có tất cả các giá trị để đưa ra câu trả lời chính xác.
Bài tập tương tự
Các bài tập dùng cùng ý tưởng. Giải hai hoặc ba bài là bạn sẽ nhớ được mẫu này.
def medianStreamOps(ops, values):
# Viết code ở đâyKiểm thử #1
Kiểm thử #2
Đối số
ops = ["addNum", "findMedian", "addNum", "findMedian", "addNum", "findMedian", "addNum", "findMedian"] values = [5, 0, 1, 0, 8, 0, 3, 0]
Đầu ra mong đợi
["null", "5", "null", "3", "null", "5", "null", "4"]