JavaScript 演算法:計算最大連續日期區間

Jeffrey Y.發表於2015-08-18

  上個星期,幫我的一個前端好友搞定了一個演算法,本人對 JavaScript 熟悉程度一般,但是瞭解 JS 的面相物件的一些東西,經過少量查閱,最終弄完了這個演算法,或許這個演算法,在 C# 或者 Java 看來並不是很難,而且由於高階語言自身的框架類庫非常的豐富,各種方法操作非常的便捷,尤其是語法糖豐富多彩的 C#,寫這些演算法,不考慮可讀性的話,應該可以以更少的程式碼來實現。

  這小東西花了一定的時間,所以特來分享。

  首先來說一下,需求的描述:該公司網站需要在下拉顯示的日曆上,底部位置顯示出使用者多選的日期,如果連續,則顯示日期範圍,比如 2015.1.1 ~ 2015.1.5;如果是單個日期,就直接顯示日期

  好了,問題來了,需求挺簡單的,但是在接下來的實現中遇到各種不大不小的坑和結節,比如:

  • 與閏年相關的二月份的總天數

  • 大月(總天數 31 天)和小月(總天數 30 天)的區別對待,以及對於 12 月的特殊對待(稍後會說到)

  • 給定的條件是一組日期字串,而不是日期物件

  • 如何判定兩個字串日期是否連續?(在這個判定的演算法上,會引發另外一些問題,其中就包括對於 12 月的特殊對待)

接下來,是我對於這個問題的分析和解決方法的步驟(如有更好的建議,歡迎詳細提出):

  • 首先,我想到的是,我們應該在日期物件上做文章,而不是單純的留在字串上,對字串進行一番折騰,這是很費時間和功夫的,而且吃力不討好。所以,我決定將字串通過一個 function 轉成 Date 物件,後續的操作可以帶來便捷。

    所以,當時第一反應,很簡單嘛,比如像下面這樣:

    var date = new Date("2015.1.1")
    

    由於該公司網站需要支援 IE 11 以下的版本,以及 Firefox,這個看似沒有問題的寫法,其實是有相容性問題的,這個語法在 IE 11 以下版本以及火狐上是沒法執行的(PS:嘿嘿,在幫助別人的同時,實際也在碎片化的積累自己的知識,不是嗎?)

    所以,經過我朋友的修改,這個方法最終變成下面這個樣子:

    String.prototype.ToDate = function ()
    {
        var parts = this.split('.');
        var date = new Date();
        date.setUTCFullYear(parts[0], parts[1] - 1, parts[2]);
        date.setUTCHours(0, 0, 0, 0);
        return date;
    }
    

    我把它融入到 prototype 裡面去,這樣程式碼看起來會比較優雅和美觀,JS 裡面的這個語法,其實和 C# 的擴充套件方法的效果是一樣的。

  • 接下來,就是排序,因為給定的日期字串是亂序的,所以,排序完之後,再對其進行演算法解析。

    顯然預設的排序規則不適合,我們需要自定義排序規則,對於兩個日期物件,我們只要比較年月日即可,如果不相等,則直接讓他們相減即可知道大小。所以,這個外部方法可以向下面這樣寫,有了它,陣列可以方便的排序了,這是第一步。

    function compare(md1, md2)
    {
        md1 = md1.ToDate();
        md2 = md2.ToDate();
        if (md1.toLocaleDateString() == md2.toLocaleDateString())
        {
            return 0;
        }
        return md1 - md2;
    }
    
  • 然後,下面的一個關鍵演算法是,如何得知相鄰的日期是否連續,這是演算法迴圈中判斷邏輯的核心,思路就是獲取前一個日期(因為經過排序,肯定小於等於後一個)的下一個日期,然後將其和第二個日期進行判等比較。

    判等不難,逐個比較年、月、日,但有一點需要注意,判斷年份相等不是 getYear 而是 getFullYear。(滿坑爹的)

    Date.prototype.equals = function (md)
    {
        if (md == null) return false;
        return this.getFullYear() == md.getFullYear() && 
               this.getMonth()    == md.getMonth() && 
               this.getDate()     == md.getDate();
    }
    

    對於獲取下一個日期的演算法,我們需要區別:是否是閏年、是大月還是小月。所以在此地,我們應該先對於月份進行判斷:

    Date.BIG_MONTH = 0; // 31 天大月
    Date.SML_MONTH = 1; // 30 天小月
    Date.FEB_MONTH = 2; // 二月
    Date.INVALID_MONTH = -1; // 月份不合法
    
    
    Date.prototype.getMonthType = function ()
    {
        var month = this.getMonth() + 1;
        // 根據總天數,對月份分組
        var months31 = "1, 3, 5, 7, 8, 10, 12";
        var months30 = "4, 6, 9, 11";
        if (months31.indexOf(month.toString()) >= 0)
        {
            return Date.BIG_MONTH;
        }
        else if (months30.indexOf(month.toString()) >= 0)
        {
            return Date.SML_MONTH;
        }
        else if (month == 2)
        {
            return Date.FEB_MONTH;
        }
        else
        {
            return Date.INVALID_MONTH;
        }
    }
    

    getMonthType 不難看懂,返回三種合法型別的月份型別,外加一個 Invalid 返回值。

    接著判斷是否是閏年,因為這個決定了二月份的總天數,總天數決定了下一天是幾月幾號。

    Date.prototype.isLeapYear = function ()
    {
        var year = this.getFullYear();
        return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
    }
    

    這個判斷閏年的演算法,就不多說了,大學課程裡 C 語言程式設計必會的基本演算法之一。

    有了上面幾個函式做鋪墊,獲取下一天的日期可以這麼寫:

    Date.prototype.getNextDate = function ()
    {
        var year = this.getFullYear();
        // 獲取月份
        var month = this.getMonth() + 1;
        // 獲取日期
        var day = this.getDate();
        switch (this.getMonthType())
        {
            // 如果是大月
            case Date.BIG_MONTH:
                if (day == 31)
                {
                    day = 1;
                    if (month == 12)
                    {
                        month = 1;
                        year += 1;
                    }
                    else month += 1;
                }
                else day += 1;
                break;
            // 如果是小月
            case Date.SML_MONTH:
                if (day == 30)
                {
                    day = 1;
                    month += 1;
                }
                else day += 1;
                break;
            // 如果是二月
            case Date.FEB_MONTH:
                var totalDays = this.isLeapYear() ? 29 : 28;
                if (day == totalDays)
                {
                    day = 1;
                    month += 1;
                }
                else day += 1;
                break;
            // 不合法月份
            default:
                return null;
        }
        return (year + "." + month + "." + day).ToDate();
    }
    

    對於 12 月,需要特殊對待,因為 12 月 31 日的下一天是 1 月 1 日,月份和日期均不能直接遞增,所以處理 12 月份的邏輯需要分開。最後一句返回一個日起物件,這麼寫是因為,new Date(year, month, day) 同樣在 IE 低版本的瀏覽器是不相容的。

    接著判斷日期 B 是不是 日期 A 的下一個日期,就比較方便了:

    Date.prototype.isNext = function (md)
    {
        var next = this.getNextDate();
        return next.equals(md);
    }
    
  • 最後,兩個演算法,一個是用來獲取一組日期中最長連續日期的範圍,另一個是將所有日期,如果連續就輸出範圍,不連續就單個輸出。

    function getLongestContinualDateRange(dates)
    {
        if (dates.length == 0) return [];
        // 如果陣列只有一個元素,直接返回陣列長度,無須比較
        if (dates.length == 1) return [new Range(0, 1)];
        // 先排序,使之成為有序陣列
        dates.sort(compare);
        // 最大連續日期範圍長度(還沒有開始比較,預設第一個日期,長度 1)
        var longest_length = 1;
        // 最大連續日期範圍的起始索引(還沒有開始比較,預設為 0)
        var longest_length_begin_index = 0;
        // 並列最大長度範圍的集合
        var ranges = [new Range(longest_length_begin_index, longest_length)];
        for (var current = 0, next = 1; next < dates.length; next++)
        {
            // 將當前索引的字串轉成日期物件
            var current_date = (current == next) ? 
                               dates[current].ToDate() : dates[next - 1].ToDate();
            var next_date = dates[next].ToDate();
            // 判斷 next_date 是否是 current_date 的明天
            if (!current_date.isNext(next_date))
            {
                // 重置下一次可能的長範圍的開頭位置
                current = next;
            }
            // 得到新的連續日期範圍的長度
            var new_length = next - current + 1;
            // 產生新的最大長度,之前的統統作廢
            if (longest_length < new_length)
            {
                longest_length = new_length;
                // 最長範圍改變的時候,可能會改變起始索引,因為可能是另外一段
                if (longest_length_begin_index != current)
                {
                    longest_length_begin_index = current;
                }
                // 清空最長範圍集合,之前的最長範圍統統作廢
                ranges.length = 0;
                ranges.unshift(new Range(longest_length_begin_index, longest_length));
            }
                // 產生新的與當前最大長度等長的日期範圍,存入 ranges 集合
            else if (longest_length == new_length)
            {
                ranges.unshift(new Range(current, new_length));
            }
        }
        return ranges;
    }
    

    演算法描述:首先對陣列進行長度判斷,如果沒有元素則直接返回空陣列,不返回 null 是為了更好的相容錯誤,如果長度等於 1,則直接返回一個長度為 1 的範圍(自定義的 Range 物件,稍後提到)。接著,假定陣列是無序的,對陣列進行從小到大的排序。定義兩個變數,分別表示最大長度和長度的起始索引,預設就是第一個元素,長度為 1。由於當存在同樣大小的範圍時,需要全部保留,將所有的 Range 加入一個集合。此處定義一個最大長度範圍的集合 ranges。

    迴圈初始的時候,current 指標指向第一個元素,next 指向它的下一個。接下來,迴圈開始轉動,current_date 被設為 next 指標指向的日期的前一個日期,但是當被重置的時候,current 指標和 next 指標指向的是同一個日期,所以此時應該取 current 的值,來判定兩者是否是連續關係。

    如果 current date 的後一天不是 next date,那麼 current 設為 next 指向的日期,以重置 range 的新開頭,說明此時 range 已經斷了,不是連續的了。

    接下來計算 currentnext 之間的跨度,如果等跨度產生,則新增至集合 ranges,如果產生新的跨度,則表明之前的 max range 統統作廢,新的日期範圍已經產生!直至跳出 for 迴圈,返回收集的最大跨度集合。

  • 取所有的日期跨度的演算法這個相對簡單一點,演算法略有不同。程式碼如下:

    function mergeToDateRange(dates)
    {
        if (dates.length == 0) return [];
        // 如果陣列只有一個元素,直接返回陣列長度,無須比較
        if (dates.length == 1) return [new Range(0, 1)];
        // 先排序,使之成為有序陣列
        dates.sort(compare);
        // 並列最大長度範圍的集合
        var ranges = new Array();
        for (var begin = 0, p = 0; p < dates.length; )
        {
            var date_p = dates[p].ToDate();
            var date_pn = (p < dates.length - 1) ? dates[p + 1].ToDate() : null;
            if (!date_p.isNext(date_pn))
            {
                ranges.push(new Range(begin, p - begin + 1));
                begin = ++p;
            }
            else
            {
                p++;
            }
        }
        return ranges;
    }
    

    核心就是,當遇到不連續的點的時候,馬上將之前的連續的範圍儲存下來,然後從新位置繼續執行計算。

    Range 類用來表示一個陣列中的連續範圍,isSingle 表示是否是單個日期,而非連續範圍。

    function Range(index, length)
    {
        this.index = index;
        this.length = length;
        this.isSingle = length == 1;
    }
    

    一個演算法的實現中,演算法思想就好比一朵花;作為綠葉,為實現演算法所做的各種‘填坑’操作以及輔助方法,也是很重要的。

測試程式碼可以這樣:

    var dateArray = ['2015.7.11', '2015.7.12', '2015.7.14', '2016.2.1', ......];
    dateArray.sort(compare);
    // 計算最大範圍日期
    var ranges = getLongestContinualDateRange(dateArray);
    document.write("最大連續日期:</br>");
    printRanges(ranges);

    // 計算所有範圍日期
    ranges = mergeToDateRange(dateArray);
    document.write("所有日期範圍:</br>");
    printRanges(ranges);
    function printRanges(ranges)
    {
        for (var i = 0; i < ranges.length; i++)
        {
            if (ranges[i].isSingle)
            {
                document.write("&nbsp;&nbsp;&nbsp;&nbsp;" + dateArray[ranges[i].index]);
            }
            else
            {
                document.write("&nbsp;&nbsp;&nbsp;&nbsp;" + dateArray[ranges[i].index] + 
                               " ~ " + dateArray[ranges[i].index + ranges[i].length - 1]);
            }
            document.write("</br>");
        }
    }

相關文章