整数のストリームから実行中の中央値を見つける


223

重複の可能性:
Cのローリングメディアンアルゴリズム

整数がデータストリームから読み取られると仮定します。これまでに読み込まれた要素の中央値を効率的な方法で見つけます。

私が読んだソリューション:左側の最大ヒープを使用して有効中央値よりも小さい要素を表し、右側の最小ヒープを使用して有効中央値よりも大きい要素を表すことができます。

着信要素を処理した後、ヒープ内の要素の数は最大で1要素だけ異なります。両方のヒープに同じ数の要素が含まれている場合、ヒープのルートデータの平均が有効な中央値であることがわかります。ヒープのバランスが取れていない場合は、より多くの要素を含むヒープのルートから有効な中央値を選択します。

しかし、最大ヒープと最小ヒープをどのように構築するのでしょうか。つまり、ここで有効な中央値をどのようにして知るのでしょうか。max-heapに1つの要素を挿入してから、min-heapに次の1つの要素を挿入する、というように、すべての要素について考えます。私がここで間違っているなら、私を訂正してください。


10
ヒープを使用した巧妙なアルゴリズム。タイトルからすぐに解決策を考えることができませんでした。
Mooing Duck

1
vizierの解決策は私には良さそうですが、このストリームは任意に長くなる可能性があるため、すべてをメモリに保持することはできないと(私は述べていませんが)想定していました。それは事実ですか?
ワイルドに実行

2
@RunningWild任意に長いストリームの場合、フィボナッチヒープを使用して(log(N)削除を取得)、挿入された要素へのポインタを順番に(たとえば、両端キューで)格納し、最も古い要素を削除することで、最後のN要素の中央値を取得できます。ヒープがいっぱいになると、各ステップの要素(おそらく1つのヒープから別のヒープに物事を移動する)。繰り返される要素の数を格納することで(Nよりもいくらか良くなる可能性があります(多くの繰り返しがある場合))、一般的に、ストリーム全体の中央値が必要な場合は、何らかの分布の仮定を行う必要があると思います。
Dougal、

2
両方のヒープを空にして開始できます。最初のintは1つのヒープに入ります。2番目は、もう一方に移動するか、最初のアイテムをもう一方のヒープに移動してから挿入します。これは一般化して、「1つのヒープが他のヒープより大きくなることを許可しない+1」であり、特別な
大文字小文字の区別

私はこの質問をMSFTのインタビューで聞きました。投稿していただきありがとうございます
R Claven

回答:


383

ストリーミングされたデータから実行中の中央値を見つけるためのさまざまなソリューションがいくつかあります。答えの最後で簡単に説明します。

問題は、特定のソリューション(最大ヒープ/最小ヒープソリューション)の詳細についてであり、ヒープベースのソリューションがどのように機能するかを以下で説明します。

最初の2つの要素については、左側のmaxHeapに小さい要素を追加し、右側のminHeapに大きい要素を追加します。次に、ストリームデータを1つずつ処理します。

Step 1: Add next item to one of the heaps

   if next item is smaller than maxHeap root add it to maxHeap,
   else add it to minHeap

Step 2: Balance the heaps (after this step heaps will be either balanced or
   one of them will contain 1 more item)

   if number of elements in one of the heaps is greater than the other by
   more than 1, remove the root element from the one containing more elements and
   add to the other one

次に、いつでも次のように中央値を計算できます。

   If the heaps contain equal amount of elements;
     median = (root of maxHeap + root of minHeap)/2
   Else
     median = root of the heap with more elements

答えの冒頭で約束したように、私は一般的に問題について話します。データのストリームから実行中の中央値を見つけることは難しい問題であり、メモリの制約がある正確な解を効率的に見つけることは、一般的なケースではおそらく不可能です。一方、データに活用できる特性があれば、効率的な専門ソリューションを開発できます。たとえば、データが整数型であることがわかっている場合は、カウントソートを使用できます、これは、一定のメモリの一定の時間アルゴリズムを提供できます。ヒープベースのソリューションは、他のデータ型(double)にも使用できるため、より一般的なソリューションです。そして最後に、正確な中央値が不要で、近似が十分な場合は、データの確率密度関数を推定し、それを使用して中央値を推定することができます。


6
これらのヒープは無制限に拡張します(つまり、100要素のウィンドウが1000万要素を超えると、1000万の要素をすべてメモリに格納する必要があります)。最近参照された100個の要素のみをメモリに保持する必要がある、インデックス付け可能なスキップリストを使用した別の回答については、以下を参照してください。
レイモンドヘッティンガー2012年

1
質問自体へのコメントの1つで説明されているように、ヒープを使用して制限付きメモリソリューションを持つこともできます。
Hakan Serce 2012年

1
ヒープベースのソリューションの実装は、cにあります。
AShelly 2014年

1
これは、この特定の問題を解決するだけでなく、ヒープを学ぶのにも役立ちました。ここでは、Pythonの基本的な実装を示します。github.com
PythonAlgo/

2
@HakanSerceなぜ私たちがやったのか説明してくれませんか?私はこの作品を見ることができますが、直感的に理解することができません。
シヴァ

51

メモリ内のすべてのアイテムを一度に保持できない場合、この問題はさらに困難になります。ヒープソリューションでは、すべての要素を一度にメモリに保持する必要があります。これは、この問題のほとんどの実際のアプリケーションでは不可能です。

代わりに、数字が表示されたら、各整数が表示される回数のカウントを追跡してください。4バイト整数、つまり2 ^ 32バケット、または最大で2 ^ 33整数(各整数のキーとカウント)であると仮定すると、2 ^ 35バイトまたは32GBです。キーを保存したり、0のエントリの数をカウントしたりする必要がないため(つまり、Pythonのdefaultdictのように)、これよりもはるかに少なくなります。新しい整数を挿入するのに一定の時間がかかります。

次に、任意の時点で中央値を見つけるには、カウントを使用して、どの整数が中間要素であるかを決定します。これには一定の時間がかかります(大きな定数ですが、それでも一定です)。


3
ほとんどすべての数値が一度表示される場合、疎リストより多くのメモリが必要になります。また、数値が多すぎて数値に収まらない場合は、ほとんどの数値が一度だけ表示される可能性が高いようです。それにもかかわらず、これは数の膨大な数の賢い解決策です。
Mooing Duck

1
まばらなリストの場合、私は同意します。これはメモリの点でより悪いです。整数がランダムに分布している場合でも、直感が意味するよりもはるかに早く重複が発生し始めます。mathworld.wolfram.com/BirthdayProblem.htmlを参照してください。したがって、数GBのデータさえあればすぐにこれが有効になると確信しています。
アンドリューC

4
@AndrewCは、中央値を見つけるのに一定の時間がかかる方法を説明できますか。n個の異なる種類の整数を見た場合、最悪の場合、最後の要素が中央値になることがあります。これにより、中央値検出O(n)アクティビティが作成されます。
shshnk 2016

@shshnkこの場合、要素の総数は>>> 2 ^ 35ではありませんか?
VishAmdi 2017年

@shshnk VishAmdiが言ったように、これは今までに見たさまざまな整数の数に対して線形であることは正しいです。 2 ^ 33より大きい。それほど多くの数が表示されない場合は、maxheapソリューションの方が確実に優れています。
アンドリューC

49

入力の分散が統計的に分布している場合(たとえば、正規、対数正規など)、貯水池のサンプリングは、任意の長い数のストリームからパーセンタイル/中央値を推定するための合理的な方法です。

int n = 0;  // Running count of elements observed so far  
#define SIZE 10000
int reservoir[SIZE];  

while(streamHasData())
{
  int x = readNumberFromStream();

  if (n < SIZE)
  {
       reservoir[n++] = x;
  }         
  else 
  {
      int p = random(++n); // Choose a random number 0 >= p < n
      if (p < SIZE)
      {
           reservoir[p] = x;
      }
  }
}

「リザーバー」は、サイズに関係なく、すべての入力の実行中の均一な(公正な)サンプルです。中央値(または任意のパーセンタイル)を見つけることは、貯水池を分類し、興味深い点を調査するという単純な問題です。

リザーバーは固定サイズであるため、ソートは事実上O(1)と見なすことができ、このメソッドは一定の時間とメモリ消費の両方で実行されます。


好奇心から、なぜ分散が必要なのですか?
LazyCat 2017年

ストリームは、リザーバーを半分空にするSIZE要素未満を返す場合があります。これは中央値を計算するときに考慮する必要があります。
Alex

中央値の代わりに差を計算することでこれをより速くする方法はありますか?削除および追加されたサンプルと以前の中央値はそのための十分な情報ですか?
inf3rno

30

私が見つけたストリームの百分位数を計算する最も効率的な方法は、P²アルゴリズムです。コミュ。ACM 28(10):1076-1085(1985)

アルゴリズムは簡単に実装でき、非常にうまく機能します。ただし、これは概算ですので、覚えておいてください。要約から:

中央値および他の分位数の動的計算のために、ヒューリスティックアルゴリズムが提案されています。推定値は、観測が生成されるときに動的に生成されます。観測は保存されません。したがって、アルゴリズムには、観測数に関係なく、非常に小さく固定されたストレージ要件があります。これにより、産業用コントローラーやレコーダーで使用できる分位チップの実装に最適です。このアルゴリズムは、ヒストグラムプロットにも拡張されています。アルゴリズムの精度が分析されます。


2
Count-Min SketchはP ^ 2よりも優れています。後者は誤差範囲を与えますが、後者はそうではありません。
sinoTrinity、2015

1
GreenwaldとKhannaによる "Quantile Summariesの空間効率の良いオンライン計算"も検討してください。これもエラーの範囲を与え、優れたメモリ要件を持っています。
Paul Chernoch 2015

1
また、確率論的なアプローチのために、このブログ記事を参照してください。research.neustar.biz/2013/09/16/... それが参照すると、用紙はこちらです: arxiv.org/pdf/1407.1121v1.pdf これは「質素と呼ばれていますストリーミング」
Paul Chernoch

27

最近見たn個の要素の中央値を求めたい場合、この問題は、 n個の最近見た要素をメモリに保持います。高速であり、拡張性に優れています。

割出し可能skiplistは、ソートされた順序を維持しながら、O(LN n)の挿入、除去、及び任意の要素のインデックス付き検索をサポートします。n番目に古いエントリを追跡するFIFOキューと組み合わせると、解決策は簡単です。

class RunningMedian:
    'Fast running median with O(lg n) updates where n is the window size'

    def __init__(self, n, iterable):
        self.it = iter(iterable)
        self.queue = deque(islice(self.it, n))
        self.skiplist = IndexableSkiplist(n)
        for elem in self.queue:
            self.skiplist.insert(elem)

    def __iter__(self):
        queue = self.queue
        skiplist = self.skiplist
        midpoint = len(queue) // 2
        yield skiplist[midpoint]
        for newelem in self.it:
            oldelem = queue.popleft()
            skiplist.remove(oldelem)
            queue.append(newelem)
            skiplist.insert(newelem)
            yield skiplist[midpoint]

以下は、完全な動作するコードへのリンクです(わかりやすいクラスバージョンと、インデックス化可能なスキップリストコードがインライン化された最適化されたジェネレーターバージョン)。


7
私がそれを正しく理解しているなら、これはあなたが見た最後のN個の要素の中央値を与えるだけで、その時点までのすべての要素ではありません。これは、その操作に対しては本当に洗練されたソリューションのように見えます。
アンドリューC

16
正しい。答えは、あたかも最後のn個の要素をメモリに保持するだけですべての要素の中央値を見つけることが可能であるかのように聞こえます。これは一般的に不可能です。アルゴリズムは、最後のn個の要素の中央値を見つけるだけです。
Hans-PeterStörr2012年

8
「ランニング中央値」という用語は、通常、データのサブセットの中央値を指すために使用されます。OPは、非標準的な方法で一般的な用語として使用されます。
レイチェルヘッティンガー2014年

18

これについて直感的に考えると、完全にバランスのとれた二分探索木がある場合、ルートには中央値の要素があり、同じ数の小さい要素と大きい要素があるためです。さて、ツリーがいっぱいでない場合、最後のレベルから欠落している要素があるため、これはまったく当てはまりません。

したがって、代わりに実行できるのは、中央値と、中央値よりも小さい要素と中央値よりも大きい要素の2つのバランスのとれた2進ツリーを持つことです。2本の木は同じサイズに保つ必要があります。

データストリームから新しい整数を取得したら、それを中央値と比較します。中央値よりも大きい場合は、適切なツリーに追加します。2つのツリーのサイズが1より大きい場合は、右側のツリーのmin要素を削除し、それを新しい中央値にして、古い中央値を左側のツリーに配置します。同様に小さい。


どうするの?「私たちは正しい木の最小要素を削除します」
ヘンガメ

2
私は二分探索木を意味していたので、min要素はルートから完全に残っています。
アイリーンPapakonstantinou 2015

7

効率的とは、コンテキストに依存する単語です。この問題の解決策は、挿入の量と比較して実行されるクエリの量によって異なります。あなたが中央値に興味を持っていた終わりに向かってN個の数値とK回を挿入するとします。ヒープベースのアルゴリズムの複雑さはO(N log N + K)になります。

次の代替案を検討してください。配列内の数値をプランクし、クエリごとに線形選択アルゴリズムを実行します(たとえば、クイックソートピボットを使用)。これで、実行時間O(KN)のアルゴリズムができました。

Kが十分に小さい場合(クエリの頻度が低い場合)、後者のアルゴリズムは実際にはより効率的であり、逆もまた同様です。


1
ヒープの例では、ルックアップは一定の時間なので、O(N log N + K)である必要があると思いますが、あなたの要点はまだ保持されています。
アンドリューC

はい、良い点は、これを編集します。そうですねN log Nはまだ主流です。
Peteris

-2

1つのヒープだけでこれを行うことはできませんか?更新:いいえ。コメントを参照してください。

不変:2*n入力を読み取った後、最小ヒープはnはそれら最大値をます。

ループ:2つの入力を読み取ります。両方をヒープに追加し、ヒープの分を削除します。これは不変条件を再確立します。

したがって、2n入力が読み取られると、ヒープの最小値はn番目に大きくなります。中央値の周りの2つの要素を平均化し、奇数の入力の後にクエリを処理するには、少し複雑になる必要があります。


1
動作しません。後で上部にあることが判明したものをドロップできます。例えば、番号1〜100を使用してアルゴリズムを試してみたが、逆の順序で:100、99、...、1
zellyn

ありがとう、zellyn。不変条件を自分に納得させる愚かな私が再建されました。
ダライアスベーコン
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.