295寻找流的中位数

1. 问题分析

    1. Find Median from Data Stream
  • 维护流数据的中值
  • https://leetcode.com/problems/find-median-from-data-stream/

中值问题,原本的中值问题,由于只需要局部有序,即使满足mid前面都小于mid,mid后都大于mid即可,因此可以采用递归partition的方法来得到O(log N)的解法。

但本问题是流问题,数据实时读入,实时找中值。

问题的核心是:

  • 有序性,这样才能方便地找中值
    • 例如有序数组,插入新数据的开销
  • 查找中值的开销

解决思路:

  • 有序性的保证,可以干脆就是全局有序的数组,那么插入这件事就是关键。例如数组的话,插入查找可以是二分查找 O ( l o g N ) O(log N) O(logN),但是复制需要O(N)。链表查找插入位置需要O(N),接入新数据需要O(1)。
  • 中值,由于是流,可以变加入边维护mid,如果实现足够好的话,完全可以在O(1)解决。

2. 解法一:排序

很简单的想法,每次那中值之前先排一下序。

  • 时间复杂度, O ( N l o g N ) O(N log N) O(NlogN)
    • 核心就是排序算法的时间复杂度了
  • 空间复杂度, O ( N ) O(N) O(N)

3. 解法二:插入维护有序数列

为了维护有序序列,每次在插入的时候保证有序。

  • 时间复杂度, O ( N ) O(N) O(N)
    • 如果底层是LinkedList,那么关键在查找插入位置,就是 O ( N ) O(N) O(N)的算法。
    • 如果底层是Array,那么查找插入位置可以使用二分搜索, O ( l o g N ) O(log N) O(logN),而插入则需要移动最差N个元素, O ( N ) O(N) O(N)
  • 空间复杂度, O ( N ) O(N) O(N)

列表实现。

难点在于维护Mid,分支很多,所以我发现画判断流程的强大之处

/*
维护有序的链表
Runtime: 507 ms, faster than 9.73% of Java online submissions for Find Median from Data Stream.
Memory Usage: 51.3 MB, less than 19.56% of Java online submissions for Find Median from Data Stream.
*/

public class MedianFinder {
    private MidOrderLinkedList list;
    /**
     * initialize your data structure here.
     */
    public MedianFinder() {
        list = new MidOrderLinkedList();
    }

    public void addNum(int num) {
        list.insert(num);
    }

    public double findMedian() {
        return list.getMidValue();
    }


}

class MidOrderLinkedList {
    private final Node head;
    private final Node last;
    private Node mid;
    private int size;

    public MidOrderLinkedList() {
        head = new Node(-Integer.MAX_VALUE);
        last = new Node(Integer.MAX_VALUE);
        head.next = last;
        last.pre = head;
        mid = head;
    }

    public void insert(int value) {
        // insert value
        Node cur, node;
        cur = mid; node = new Node(value);
        if (value < mid.val) while (value < cur.val) cur = cur.pre;
        else while (value >= cur.next.val) cur = cur.next;
        node.pre = cur;
        node.next = cur.next;
        cur.next.pre = node;
        cur.next = node;
        size++;
        // update mid
        if ((size & 1) == 1) {
            if (value >= mid.val) mid = mid.next;
        } else {
            if (value < mid.val) mid = mid.pre;
        }
        disp();
    }

    public double getMidValue() {
        return ((size & 1) == 1) ? mid.val : (double) ((mid.val + mid.next.val) / 2.0);
    }
    private void disp() {
        Node cur = head.next;
        while (cur != last) {
            System.out.print(cur.val + " ");
            cur = cur.next;
        }

        System.out.println("" +
                "mid: " + mid.val + "mid val: " + getMidValue());
    }

    public int size() {
        return size;
    }
}

class Node {
    int val;
    Node pre, next;

    public Node(int val) {
        this.val = val;
    }

    public Node() {
    }
}

其实数组的实现应该才是最简单的,但是JAVA没有提供C++那种low_bound——即使二分搜索不到也返回一个最近的下边界这种接口。

// https://leetcode.com/problems/find-median-from-data-stream/solution/
class MedianFinder {
    vector<int> store; // resize-able container

public:
    // Adds a number into the data structure.
    void addNum(int num)
    {
        if (store.empty())
            store.push_back(num);
        else
            store.insert(lower_bound(store.begin(), store.end(), num), num);     // binary search and insertion combined
    }

    // Returns the median of current data stream
    double findMedian()
    {
        int n = store.size();
        return n & 1 ? store[n / 2] : ((double) store[n / 2 - 1] + store[n / 2]) * 0.5;
    }
};

4. 解法二:BST

有序性,很容易想到二叉搜索树或者平衡二叉树,下面只以BST为例,后者和BST相同,只不过AVL的特性保证了严格的 O ( l o g N ) O(logN) O(logN)操作。

思路:

  • 有序性由BST的特性保证
  • 那如何在BST中搜索中值呢?
    • 实现思路一,维护mid node,实现从mid node找到前后继节点的方法,最简单的,持有parent的引用即可。因为对于中值来说,每次不管加入的数据大于或者小于mid,mid都只需要根据插入的值,左移右移或不动。因而在BST中只要mid node能够实时找到前后继即可。好的实现应该可以在O(1)实现mid node的维护。然后插入值部分就是整体的复杂度关键,最差是 O ( N ) O(N) O(N),我们可以期望,并且如果是AVL则可以是严格是 O ( l o g N ) O(log N) O(logN)
    • 实现思路二,思路一需要mid node繁琐的维护过程,这个思路则是增加一些时间开销,减少实现的难度。即,在每次查找中值的时候再搜索前后继。好的实现应该只需要 O ( l o g N ) O(logN) O(logN),直接就类似BST的查找。

5. 解法三,大小堆

这是一种非常漂亮的实现,经典的思想。

关键在于,既然只需要中值,那么全局有序就不是必须的,及比如朴素寻找中值的partition算法。

维护大小堆,顶部为中值相关的值。具体实现如下:

/*
大顶堆,小顶堆。
时间复杂度,维护有序性(准确来说,部分有序即可)没问题;插入数据,查找+插入,O(logN),寻找中值,O(1)
寻找中值相比于排序,最重要的优化key——不必全局有序,部分有序,可以考虑堆。
Runtime: 120 ms, faster than 12.66% of Java online submissions for Find Median from Data Stream.
        Memory Usage: 50.9 MB, less than 23.53% of Java online submissions for Find Median from Data Stream.
Runtime: 43 ms, faster than 95.39% of Java online submissions for Find Median from Data Stream.
Memory Usage: 50.4 MB, less than 54.44% of Java online submissions for Find Median from Data Stream.
*/

public class MedianFinderV2 {
    private PriorityQueue<Integer> maxHead, minHead;
    private int left, right;
    public MedianFinderV2() {
        maxHead = new PriorityQueue<>((a, b) -> b - a);
        minHead = new PriorityQueue<>();

    }

    public void addNum(int num) {
        if (right != 0 && num < minHead.peek()) {
            maxHead.offer(num);
            left++;
        } else {
            minHead.offer(num);
            right++;
        }
        int dif = right - left;
        if (dif == 2) {
            int top = minHead.poll();
            right--;
            maxHead.offer(top);
            left++;
        }
        if (dif == -2) {
            int top = maxHead.poll();
            left--;
            minHead.offer(top);
            right++;
        }
    }

    public double findMedian() {
        if (left < right)return minHead.peek();
        else if (left > right) return maxHead.peek();
        return (maxHead.peek() + minHead.peek()) / 2.0;
    }
}

另一种简洁的版本。

其中addNum的实现极为有意思,它要求通过size判断应该插入那个堆,插入小堆之前,必须先插入大堆,插入大堆之前必须插入小堆。

/*
非常简洁的实现,但是在时间复杂度上,会稍微多一个系数,原因是每次add必定是三次的O(log N)操作
Runtime: 48 ms, faster than 72.61% of Java online submissions for Find Median from Data Stream.
Memory Usage: 50.1 MB, less than 83.21% of Java online submissions for Find Median from Data Stream.
*/

public class MedianFinderV2_1 {
    private PriorityQueue<Integer> maxHead, minHead;
    public MedianFinderV2_1() {
        maxHead = new PriorityQueue<>((a, b) -> b - a);
        minHead = new PriorityQueue<>();

    }

    public void addNum(int num) {
        if (maxHead.size() < minHead.size()) {
            minHead.offer(num);
            maxHead.offer(minHead.poll());
        } else {
            maxHead.offer(num);
            minHead.offer(maxHead.poll());
        }
        System.out.println(findMedian());
    }

    public double findMedian() {
        if (maxHead.size() == minHead.size())return (maxHead.peek() + minHead.peek()) / 2.0;
        return minHead.peek();
    }
}

  • 时间复杂度, O ( l o g N ) O(log N) O(logN)
    • 局部有序性由堆特性保证
    • 维护两个堆的size一样或相差1,这件事需要不断插入和移除数据,因而每次插入数据的时间复杂度为 O ( l o g N ) O(log N) O(logN)

6. 解法四,桶与分段

这里是关于继续优化的思考。

桶是一种神奇的数据堆结构。

  • 保证有序性只需要 O ( 1 ) O(1) O(1)
  • 插入新数据只需要 O ( 1 ) O(1) O(1)

但是它必须要求已知数据范围。例如0-100.

  • 直接用桶放数据
  • 查找mid,通过size得到mid的rank,可以直接是常数复杂度,具体而言是 O ( K ) , K = 100 O(K), K=100 O(K),K=100
  • 维护mid的方法,也完全可以实现,复杂度 O ( 1 ) O(1) O(1)

follow up中的问题,如果99%的数据是0-100,那么可以如何优化?

  • 答案是范围内的使用桶,范围外的使用其他有序list实现
  • 实现比较复杂

更一步,桶的思想可以考虑到分段树。即,某个范围内的我就放在一个桶中,桶中使用list。这是一种广泛意义上的桶,是一种实现意义上的优化。

REF

  • 官方详细的解,https://leetcode.com/problems/find-median-from-data-stream/solution/
  • BST,https://leetcode.com/problems/find-median-from-data-stream/discuss/74119/18ms-beats-100-Java-Solution-with-BST
  • 大小堆,https://labuladong.gitbook.io/algo/shu-ju-jie-gou-xi-lie/shou-ba-shou-she-ji-shu-ju-jie-gou/shu-ju-liu-zhong-wei-shu
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值