1. 程式人生 > >劍指Offer-65-資料流中的中位數

劍指Offer-65-資料流中的中位數

專案地址:https://github.com/SpecialYy/Sword-Means-Offer

問題

如何得到一個數據流中的中位數?如果從資料流中讀出奇數個數值,那麼中位數就是所有數值排序之後位於中間的數值。如果從資料流中讀出偶數個數值,那麼中位數就是所有數值排序之後中間兩個數的平均值。我們使用Insert()方法讀取資料流,使用GetMedian()方法獲取當前讀取資料的中位數。

解析

這道題有2個要求:

  1. 能夠存放輸入的每一個數
  2. 能夠時刻從當前已存的書中獲取中間值,奇數時返回最中間那一個就行;偶數時位於中間有2個數,所以返回兩者的平均值即可。

對於第一個要求,必然不能使用固定大小的容器,因為插入的操作次數是不確定的。這裡我們可以使用自帶擴容的陣列或者無界的連結串列,當然我們還可以使用各個程式語言中已經實現的容器。第二要求才是本題的關鍵,如何快速獲得中間值其實依賴於第一步我們選擇的容器。

思路一

我們實現一個自動擴容的陣列,在新增元素時若發當前陣列已滿,則會新建一個大小為當前陣列長度為2倍的陣列,然後把原陣列的內容copy到新的陣列上,最後把待插入的元素放到新的陣列即可。

那麼我們如何獲得中間值呢?這裡有2種做法:

  1. 利用快排的思想來獲取第K大的值,選擇一個pivot,通過partation來將陣列分為兩部分,使得左邊的數都比pivot小,右邊的數都比pivot大。然後檢視pivot所處的位置是否是K,若是K,則pivot就是第K大數。若小於K,則說明第K大在右邊;若大於K,則說明第K大在左邊;遞迴處理即可,直到pivot的索引為K為止。
  2. 我們在插入的時候就保持陣列有序,這樣就可以根據資料隨機訪問的特性獲得中間的值。插入排序的思路可以使得插入資料保持陣列有序。
	//  方法一: 自動擴容陣列法
    class AdaptiveArray {
        int size = 0;
        int[] value;

        public AdaptiveArray() {
            value = new int[10];
        }

        public AdaptiveArray(int capacity) {
            value = new int[capacity];
        }

        public void addElementByOrder(int item) {
            if (size == value.length) {
                Expansion(size);
            }
            value[size++] = item;
            for (int i = size - 1; i > 0 && value[i] < value[i - 1]; i--) {
                int temp = value[i];
                value[i] = value[i - 1];
                value[i - 1] = temp;
            }
        }

        public double getMiddleNumber() {
            if (size == 0) {
                return 0;
            }
            if((size & 1) == 1) {
                return value[size / 2];
            } else {
                int mid = size / 2;
                return ((double) value[mid] + value[mid - 1]) / 2;
            }
        }

        public void Expansion(int length) {
            int[] newValue = new int[length << 1];
            System.arraycopy(value, 0, newValue, 0, length);
            value = newValue;
        }

    }

    AdaptiveArray adaptiveArray = new AdaptiveArray();

    public void Insert(Integer num) {
        adaptiveArray.addElementByOrder(num);
    }

    public Double GetMedian() {
        return adaptiveArray.getMiddleNumber();
    }

思路二

陣列擴容的過程會涉及到複製操作,不僅浪費時間,而且還浪費空間。只有記憶體足夠大,才能保證能夠再申請額外的空間用於拷貝原陣列。而連結串列是通過指標連線起來的,它不要求佔用的記憶體是連續的,所以可以有效的解決記憶體碎片化問題。新增一個節點只需更改節點之間的指向關係即可,快速便捷。

我們這裡還是以插入的時候保持有序來方便我們獲取中間節點。注意在節點總數為偶數時,我們獲得中間兩個節點的第一個即可,然後根據指標即可獲得中間節點的第二個節點。

獲得中間節點的思路如下,利用2個快慢指標,慢指標一次走一步,快指標一次走2步。然後令快指標始終指向偶數位置,這樣的話,當總節點數為奇數時,快指標走到空指標,慢指標正好指向最中間的節點。當總節點數為偶數時,快指標走到末尾時,慢指標正好指向兩個中間節點的前一個。然後我們根據next指標即可獲得兩個中間節點的後一個。

// 方法二:連結串列法

    class ListNode {
        int value;
        ListNode next;

        public ListNode(int value) {
            this.value = value;
        }
    }

    ListNode head = new ListNode(0);
    ListNode midLeft = null, midRight = null;
    int size = 0;

    public void Insert1(Integer num) {
        ListNode listNode = new ListNode(num);
        ListNode p = head;
        while (p.next != null && p.next.value < num) {
            p = p.next;
        }
        listNode.next = p.next;
        p.next = listNode;
        midLeft = findMiddleListNode(head.next);
        size++;
        if ((size & 1) == 1) {
            midRight = midLeft;
        } else {
            midRight = midLeft.next;
        }
    }

    public Double GetMedian1() {
        return ((double) midLeft.value + midRight.value) / 2;
    }

    /**
     * 尋找連結串列的中間節點
     * @param node
     * @return
     */
    public ListNode findMiddleListNode(ListNode node) {
        if (node == null || node.next == null) {
            return node;
        }
        ListNode first = node;
        ListNode second = node.next;
        while(second != null && second.next != null) {
            first = first.next;
            second = second.next.next;
        }
        return first;
    }

思路三

我們的目標是獲得中間節點,那麼根據中間節點可以把陣列劃分為2部分。中間節點正好是左邊部分的最大值和右邊部分的最小值。**所以我們只要能夠快速獲得左邊部分最大值和右邊部分的最小值即可。**這屬於Top-K問題,所以首選堆排序,堆排序對應的一個很好的應用就是優先佇列。該資料結構的實現在C++和Java中都有很好的實現。

申請一個最大堆和一個最小堆。我們規定當節點數為奇數時,中間節點位於最大堆中,表明左部分比右部分多一個節點。當節點數為偶數時,2箇中間節點分別位於最大堆和最小堆中,表明左右部分節點數一樣。

  1. 當前節點數為偶數個,所以需要在左部分新新增一個節點。這個節點還必須比右部分都小,所以我們可以把待插入的節點先插入最小堆,然後把最小堆的頭部彈出並加入最大堆。
  2. 當前節點數為奇數個,所以需要在右部分新增一個節點。這個節點還必須比左部分都大,所以我們可以把待插入的節點先插入最大堆,然後把最大堆的頭部彈出並加入到最小堆中。
	//方法3:最大堆和最小堆

    PriorityQueue<Integer> minHeap = new PriorityQueue<>();
    PriorityQueue<Integer> maxHeap = new PriorityQueue<>(new Comparator<Integer>() {
        @Override
        public int compare(Integer o1, Integer o2) {
            return o2 - o1;
        }
    });
	int size = 0;
	
	/**
     * 這裡還是規定了當節點數為奇數時,中間節點歸在最大堆裡
     * 運用了巧妙的方法:
     * 1.當節點數為奇數,表明要在最小堆裡增加一個節點
     * 但是我們不能直接插入最小堆,先插入最大堆,然後彈出插入最小堆
     * 2.當節點數為偶數,表明要在最大堆裡增加一個節點
     * 類似情況1,先插最小堆,然後再插最大堆
     * @param num
     */
    public void Insert3(Integer num) {
        if ((size & 1) == 0) {
            minHeap.offer(num);
            maxHeap.offer(minHeap.poll());
        } else {
            maxHeap.offer(num);
            minHeap.offer(maxHeap.poll());
        }
        size++;
    }

	public Double GetMedian3() {
        return maxHeap.size() == minHeap.size() ?
                ((double) maxHeap.peek() + minHeap.peek()) / 2 : (double) maxHeap.peek();
    }

總結

多聯想一些常用的資料結構來輔助解題。