beam: sliding window median with two-heap lazy deletion + tests
This commit is contained in:
1 parent
dfae586ad0
commit
c3ce8dd95e
2 files changed
+158
No files matched your search
@@ -0,0 +1,108 @@
|
||||
"""Sliding-window median using two heaps with lazy deletion.
|
||||
|
||||
Public API:
|
||||
sliding_window_median(nums, k) -> list[float]
|
||||
Median of each contiguous window of size k. For even k, median is the
|
||||
average of the two middle values. Empty input / k<=0 -> [].
|
||||
SlidingWindowMedian(k) -> incremental iterator-friendly helper with
|
||||
.add(x), .median().
|
||||
"""
|
||||
import heapq
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
class _DualHeap:
|
||||
def __init__(self):
|
||||
self.lo = [] # max-heap (negated) for lower half
|
||||
self.hi = [] # min-heap for upper half
|
||||
self.delayed = defaultdict(int) # value -> pending deletions
|
||||
self.lo_size = 0 # valid count in lo
|
||||
self.hi_size = 0 # valid count in hi
|
||||
|
||||
def _prune(self, heap):
|
||||
while heap:
|
||||
v = -heap[0] if heap is self.lo else heap[0]
|
||||
if self.delayed.get(v, 0):
|
||||
heapq.heappop(heap)
|
||||
self.delayed[v] -= 1
|
||||
if self.delayed[v] == 0:
|
||||
del self.delayed[v]
|
||||
else:
|
||||
break
|
||||
|
||||
def _balance(self):
|
||||
# lo may have at most one more than hi
|
||||
if self.lo_size > self.hi_size + 1:
|
||||
self._prune(self.lo)
|
||||
v = -heapq.heappop(self.lo)
|
||||
self.lo_size -= 1
|
||||
heapq.heappush(self.hi, v)
|
||||
self.hi_size += 1
|
||||
self._prune(self.lo)
|
||||
elif self.lo_size < self.hi_size:
|
||||
self._prune(self.hi)
|
||||
v = heapq.heappop(self.hi)
|
||||
self.hi_size -= 1
|
||||
heapq.heappush(self.lo, -v)
|
||||
self.lo_size += 1
|
||||
self._prune(self.hi)
|
||||
|
||||
def add(self, num):
|
||||
if not self.lo or num <= -self.lo[0]:
|
||||
heapq.heappush(self.lo, -num)
|
||||
self.lo_size += 1
|
||||
else:
|
||||
heapq.heappush(self.hi, num)
|
||||
self.hi_size += 1
|
||||
self._balance()
|
||||
|
||||
def remove(self, num):
|
||||
self.delayed[num] += 1
|
||||
if num <= -self.lo[0]:
|
||||
self.lo_size -= 1
|
||||
if num == -self.lo[0]:
|
||||
self._prune(self.lo)
|
||||
else:
|
||||
self.hi_size -= 1
|
||||
self._balance()
|
||||
|
||||
def median(self):
|
||||
if self.lo_size == 0 and self.hi_size == 0:
|
||||
raise ValueError("empty")
|
||||
self._prune(self.lo)
|
||||
self._prune(self.hi)
|
||||
if self.lo_size > self.hi_size:
|
||||
return -self.lo[0]
|
||||
return (-self.lo[0] + self.hi[0]) / 2.0
|
||||
|
||||
|
||||
class SlidingWindowMedian:
|
||||
def __init__(self, k):
|
||||
if k <= 0:
|
||||
raise ValueError("k must be positive")
|
||||
self.k = k
|
||||
self.dh = _DualHeap()
|
||||
self.buf = []
|
||||
|
||||
def add(self, x):
|
||||
self.dh.add(x)
|
||||
self.buf.append(x)
|
||||
if len(self.buf) > self.k:
|
||||
old = self.buf.pop(0)
|
||||
self.dh.remove(old)
|
||||
return self.median() if len(self.buf) == self.k else None
|
||||
|
||||
def median(self):
|
||||
return self.dh.median()
|
||||
|
||||
|
||||
def sliding_window_median(nums, k):
|
||||
if k <= 0 or not nums:
|
||||
return []
|
||||
swm = SlidingWindowMedian(k)
|
||||
out = []
|
||||
for n in nums:
|
||||
m = swm.add(n)
|
||||
if m is not None:
|
||||
out.append(m)
|
||||
return out
|
||||
@@ -0,0 +1,50 @@
|
||||
import random, statistics
|
||||
from sliding_window_median import sliding_window_median, SlidingWindowMedian
|
||||
|
||||
def brute(nums, k):
|
||||
out=[]
|
||||
for i in range(len(nums)-k+1):
|
||||
w=sorted(nums[i:i+k])
|
||||
if k%2: out.append(float(w[k//2]))
|
||||
else: out.append((w[k//2-1]+w[k//2])/2.0)
|
||||
return out
|
||||
|
||||
def test_basic():
|
||||
assert sliding_window_median([1,3,-1,-3,5,3,6,7],3)==[1.0, -1.0, -1.0, 3.0, 5.0, 6.0]
|
||||
|
||||
def test_even():
|
||||
assert sliding_window_median([1,2,3,4],2)==[1.5,2.5,3.5]
|
||||
|
||||
def test_single():
|
||||
assert sliding_window_median([5],1)==[5.0]
|
||||
|
||||
def test_empty():
|
||||
assert sliding_window_median([],3)==[]
|
||||
assert sliding_window_median([1,2],0)==[]
|
||||
|
||||
def test_random():
|
||||
for _ in range(300):
|
||||
n=random.randint(1,40)
|
||||
k=random.randint(1,n)
|
||||
nums=[random.randint(-20,20) for _ in range(n)]
|
||||
got=sliding_window_median(nums,k)
|
||||
exp=brute(nums,k)
|
||||
assert len(got)==len(exp)
|
||||
for a,b in zip(got,exp):
|
||||
assert abs(a-b)<1e-9, (nums,k,a,b)
|
||||
|
||||
def test_incremental():
|
||||
swm=SlidingWindowMedian(3)
|
||||
assert swm.add(1) is None
|
||||
assert swm.add(3) is None
|
||||
assert swm.add(-1)==1.0
|
||||
assert swm.add(-3)==-1.0
|
||||
|
||||
def test_duplicates_removal():
|
||||
# duplicates where removal ambiguity exists
|
||||
assert sliding_window_median([2,2,2,2],2)==[2.0,2.0,2.0]
|
||||
|
||||
if __name__=="__main__":
|
||||
for name in [n for n in globals() if n.startswith("test_")]:
|
||||
globals()[name]()
|
||||
print("ALL PASS")
|
||||
Reference in new issue
Block a user