Median of Stream of Integers menggunakan Heap di Java

1. Gambaran keseluruhan

Dalam tutorial ini, kita akan belajar bagaimana mengira median aliran bilangan bulat.

Kami akan meneruskan dengan menyatakan masalah dengan contoh, kemudian menganalisis masalah, dan akhirnya menerapkan beberapa penyelesaian di Java.

2. Penyataan Masalah

Median adalah nilai tengah bagi set data yang dipesan. Untuk sekumpulan bilangan bulat, terdapat banyak elemen yang kurang daripada median yang lebih besar.

Dalam set pesanan:

  • bilangan bulat ganjil, elemen tengah adalah median - dalam set tertib {5, 7, 10} , median adalah 7
  • genap bilangan bulat, tidak ada unsur tengah; median dikira sebagai purata dua elemen tengah - dalam set tertib {5, 7, 8, 10} , median adalah (7 + 8) / 2 = 7.5

Sekarang, mari kita anggap bahawa bukannya satu set terhingga, kita membaca bilangan bulat dari aliran data. Kita boleh menentukan median aliran bilangan bulat sebagai median bagi set bilangan bulat yang dibaca setakat ini .

Mari kita memformalkan penyataan masalah. Memandangkan input aliran bilangan bulat, kita mesti merancang kelas yang melakukan dua tugas berikut untuk setiap bilangan bulat yang kita baca:

  1. Tambahkan bilangan bulat ke set bilangan bulat
  2. Cari median bilangan bulat yang dibaca setakat ini

Sebagai contoh:

add 5 // sorted-set = { 5 }, size = 1 get median -> 5 add 7 // sorted-set = { 5, 7 }, size = 2 get median -> (5 + 7) / 2 = 6 add 10 // sorted-set = { 5, 7, 10 }, size = 3 get median -> 7 add 8 // sorted-set = { 5, 7, 8, 10 }, size = 4 get median -> (7 + 8) / 2 = 7.5 .. 

Walaupun alirannya tidak terbatas, kami dapat menganggap bahawa kami dapat menyimpan semua elemen aliran dalam memori sekaligus.

Kami dapat mewakili tugas kami sebagai operasi berikut dalam kod:

void add(int num); double getMedian(); 

3. Pendekatan naif

3.1. Senarai Tersusun

Mari kita mulakan dengan idea mudah - kita dapat menghitung median dari senarai bilangan bulat yang disusun dengan mengakses elemen tengah atau dua elemen tengah dari senarai , mengikut indeks. Kerumitan masa operasi getMedian adalah O (1) .

Semasa menambahkan bilangan bulat baru, kita mesti menentukan kedudukannya yang betul dalam senarai supaya senarai tetap disusun. Operasi ini dapat dilakukan dalam waktu O (n) , di mana n adalah ukuran senarai . Jadi, kos keseluruhan untuk menambahkan elemen baru ke dalam senarai dan mengira median baru adalah O (n) .

3.2. Memperbaiki Pendekatan Naif

The add larian operasi dalam masa linear, yang tidak optimum. Mari cuba mengatasinya di bahagian ini.

Kita boleh membahagikan senarai menjadi dua senarai yang diurutkan - separuh bilangan bulat yang lebih kecil disusun mengikut urutan menurun, dan separuh bilangan bulat yang lebih besar dalam susunan yang bertambah . Kita boleh menambahkan bilangan bulat baru ke dalam setengah yang sesuai sehingga ukuran senarai berbeza dengan 1, paling banyak:

if element is smaller than min. element of larger half: insert into smaller half at appropriate index if smaller half is much bigger than larger half: remove max. element of smaller half and insert at the beginning of larger half (rebalance) else insert into larger half at appropriate index: if larger half is much bigger than smaller half: remove min. element of larger half and insert at the beginning of smaller half (rebalance) 

Sekarang, kita dapat menghitung mediannya:

if lists contain equal number of elements: median = (max. element of smaller half + min. element of larger half) / 2 else if smaller half contains more elements: median = max. element of smaller half else if larger half contains more elements: median = min. element of larger half

Walaupun kami hanya memperbaiki kerumitan waktu operasi tambah dengan beberapa faktor tetap, kami telah membuat kemajuan.

Mari kita analisis elemen yang kita akses dalam dua senarai yang disusun . Kami berpotensi mengakses setiap elemen semasa kami mengalihkannya semasa operasi tambah (disusun) . Lebih penting lagi, kita mengakses minimum dan maksimum (ekstrimum) pada bahagian yang lebih besar dan lebih kecil masing-masing, semasa operasi tambah untuk pengimbangan semula dan semasa operasi getMedian .

Kita dapat melihat bahawa ekstrem adalah elemen pertama dalam senarai masing-masing . Jadi, kita mesti mengoptimumkan untuk mengakses elemen pada indeks 0 untuk setiap separuh untuk meningkatkan keseluruhan masa operasi operasi tambah .

4. Pendekatan berdasarkan timbunan

Mari perbaiki pemahaman kita mengenai masalah ini, dengan menerapkan apa yang telah kita pelajari dari pendekatan naif kita:

  1. Kita mesti elemen minimum / maksimum set data dalam O (1) masa
  2. Elemen tidak perlu disimpan dalam urutan selagi kita dapat memperoleh elemen minimum / maksimum dengan cekap
  3. Kita perlu mencari pendekatan untuk menambah elemen untuk set data kami yang kos kurang daripada O (n) masa

Seterusnya, kita akan melihat struktur data Heap yang membantu kita mencapai matlamat dengan cekap.

4.1. Struktur Data timbunan

Heap adalah struktur data yang biasanya dilaksanakan dengan tatasusunan tetapi dapat dianggap sebagai pohon binari .

Tumpukan dikekang oleh harta timbunan:

4.1.1. Harta maksimum - timbunan

Node (anak) tidak boleh mempunyai nilai yang lebih besar daripada nilai induknya. Oleh itu, dalam timbunan maksimum , simpul akar selalu mempunyai nilai terbesar.

4.1.2. Min - Hartanah timbunan

Nod (ibu bapa) tidak boleh mempunyai nilai yang lebih besar daripada nilai anak-anaknya. Oleh itu, dalam timbunan min , simpul akar selalu mempunyai nilai terkecil.

Di Jawa, kelas PriorityQueue mewakili timbunan. Mari maju ke penyelesaian pertama kami menggunakan timbunan.

4.2. Penyelesaian Pertama

Mari ganti senarai dalam pendekatan naif kami dengan dua timbunan:

  • Timbunan min yang mengandungi separuh elemen yang lebih besar, dengan elemen minimum pada akar
  • Tumpukan maksimum yang mengandungi separuh elemen yang lebih kecil, dengan elemen maksimum pada akar

Now, we can add the incoming integer to the relevant half by comparing it with the root of the min-heap. Next, if after insertion, the size of one heap differs from that of the other heap by more than 1, we can rebalance the heaps, thus maintaining a size difference of at most 1:

if size(minHeap) > size(maxHeap) + 1: remove root element of minHeap, insert into maxHeap if size(maxHeap) > size(minHeap) + 1: remove root element of maxHeap, insert into minHeap

With this approach, we can compute the median as the average of the root elements of both the heaps, if the size of the two heaps is equal. Otherwise, the root element of the heap with more elements is the median.

We'll use the PriorityQueue class to represent the heaps. The default heap property of a PriorityQueue is min-heap. We can create a max-heap by using a Comparator.reverserOrder that uses the reverse of the natural order:

class MedianOfIntegerStream { private Queue minHeap, maxHeap; MedianOfIntegerStream() { minHeap = new PriorityQueue(); maxHeap = new PriorityQueue(Comparator.reverseOrder()); } void add(int num) { if (!minHeap.isEmpty() && num  minHeap.size() + 1) { minHeap.offer(maxHeap.poll()); } } else { minHeap.offer(num); if (minHeap.size() > maxHeap.size() + 1) { maxHeap.offer(minHeap.poll()); } } } double getMedian() { int median; if (minHeap.size()  maxHeap.size()) { median = minHeap.peek(); } else { median = (minHeap.peek() + maxHeap.peek()) / 2; } return median; } }

Before we analyze the running time of our code, let's look at the time complexity of the heap operations we have used:

find-min/find-max O(1) delete-min/delete-max O(log n) insert O(log n) 

So, the getMedian operation can be performed in O(1) time as it requires the find-min and find-max functions only. The time complexity of the add operation is O(log n) – three insert/delete calls each requiring O(log n) time.

4.3. Heap Size Invariant Solution

In our previous approach, we compared each new element with the root elements of the heaps. Let's explore another approach using heap in which we can leverage the heap property to add a new element in the appropriate half.

As we have done for our previous solution, we begin with two heaps – a min-heap and a max-heap. Next, let's introduce a condition: the size of the max-heap must be (n / 2) at all times, while the size of the min-heap can be either (n / 2) or (n / 2) + 1, depending on the total number of elements in the two heaps. In other words, we can allow only the min-heap to have an extra element, when the total number of elements is odd.

With our heap size invariant, we can compute the median as the average of the root elements of both heaps, if the sizes of both heaps are (n / 2). Otherwise, the root element of the min-heap is the median.

When we add a new integer, we have two scenarios:

1. Total no. of existing elements is even size(min-heap) == size(max-heap) == (n / 2) 2. Total no. of existing elements is odd size(max-heap) == (n / 2) size(min-heap) == (n / 2) + 1 

We can maintain the invariant by adding the new element to one of the heaps and rebalancing every time:

The rebalancing works by moving the largest element from the max-heap to the min-heap, or by moving the smallest element from the min-heap to the max-heap. This way, though we're not comparing the new integer before adding it to a heap, the subsequent rebalancing ensures that we honor the underlying invariant of smaller and larger halves.

Let's implement our solution in Java using PriorityQueues:

class MedianOfIntegerStream { private Queue minHeap, maxHeap; MedianOfIntegerStream() { minHeap = new PriorityQueue(); maxHeap = new PriorityQueue(Comparator.reverseOrder()); } void add(int num) { if (minHeap.size() == maxHeap.size()) { maxHeap.offer(num); minHeap.offer(maxHeap.poll()); } else { minHeap.offer(num); maxHeap.offer(minHeap.poll()); } } double getMedian() { int median; if (minHeap.size() > maxHeap.size()) { median = minHeap.peek(); } else { median = (minHeap.peek() + maxHeap.peek()) / 2; } return median; } }

The time complexities of our operations remain unchanged: getMedian costs O(1) time, while add runs in time O(log n) with exactly the same number of operations.

Kedua-dua penyelesaian berasaskan timbunan ini menawarkan kerumitan ruang dan masa yang serupa. Walaupun penyelesaian kedua adalah pintar dan mempunyai implementasi yang lebih bersih, pendekatannya tidak intuitif. Sebaliknya, penyelesaian pertama mengikuti intuisi kita secara semula jadi, dan lebih mudah untuk memikirkan kebenaran operasi tambahnya .

5. Kesimpulannya

Dalam tutorial ini, kami belajar bagaimana mengira median aliran bilangan bulat. Kami menilai beberapa pendekatan dan menerapkan beberapa penyelesaian yang berbeza di Java menggunakan PriorityQueue .

Seperti biasa, kod sumber untuk semua contoh boleh didapati di GitHub.