1. 程式人生 > >險些翻車,差一點沒做出來的基礎演算法題

險些翻車,差一點沒做出來的基礎演算法題

大家好,歡迎大家閱讀週末演算法題專題。

今天我們選擇的題目是codeforces 1405比賽的C題。

題目連結:https://codeforces.com/contest/1405/problem/C

這道題有6800多人通過,怎麼看也不算是難題,但是我做了一上午都沒能AC。最後又苦思冥想了很久,才最終做出來。做出來之後的第一感覺就是這道題太牛了,值得一說,算是那種誰都能看懂題意,都能想想辦法,但是能做出來很不容易的問題。

還是一如既往的codeforces賽題的風格,不嚴格考察演算法,你做不出來大概率不是因為知道的演算法不夠多,而是因為你思維能力不夠。

題意

給定一個字串,字串當中只包含三種字元,分別是0,1和?。 ?表示既可以是0也可以是1。現在呢,給定一個整數k,k表示滑動視窗的長度。我們需要從頭開始將一個滑動視窗向字串末尾移動,很明顯,不管我們怎麼移動,滑動窗口裡的字元的數量應該都是k個。

由於存在?既可以是0也可以是1,我們希望我們能找到一種方案,把一部分?變成0,另外一部分變成1。使得在這個視窗滑動的過程當中,窗口裡的0的數量和1的數量相等。

給定字串以及k,要求返回YES或NO,YES表示存在這樣的方案,NO表示不存在。

這是一道多組測試資料的問題,首先給定一個t表示資料組數。對於每一組資料首先給定n和k兩個整數,n表示字串的長度,k表示滑動視窗的長度。接著給定一個字串,保證字串當中只有0,1和?,並且字串的長度為n。

其中

樣例

心路歷程

首先通過給定的資料範圍我們可以確定一點,就是如果我們一個滑動視窗一個滑動視窗地判斷一定會超時。因為最壞情況下, ,這時滑動視窗的數量一共也是k個,對於每一個視窗我們需要遍歷一遍。所以整體的複雜度是

,對於1e5的資料範圍來說這一定是不能接受的。

於是我轉變思路,決定從整體入手。怎麼入手呢?

整體入手

對於每一個滑動視窗來說都要保證其中0和1的數量相等,我們觀察一下會發現,每一個位置的字元一共出現的次數是不同的。比如10?1?0這個字串,我們假設k=4。我們會發現第0位的字元1只在1個窗口出現,第1位的0會在兩個滑動窗口出現。對於每一個視窗我們都要保證0和1的數量一樣多,那麼也就是說我們要保證這些窗口出現的0和1的總數累加在一起應該一樣多。

所以對於字串當中的每一位,我們都計算它們的貢獻度,貢獻度就是總共出現的次數。這個值其實很好算,就是 。比如第0位的1只出現了一次,所以貢獻度就是1,第1位的0出現了兩次,貢獻度就是2。對於?來說我們是不確定它們貢獻是0還是1的,但可以肯定的是貢獻度是確定的。所以我們用一個數組來儲存下來它們的貢獻度。

最終我們可以得到兩個數,分別是0的所有貢獻度,1的貢獻度以及**?組成的貢獻度陣列**。我們要做的就是從?組成的貢獻度陣列當中選出一些來變成0,另外一些變成1,最後讓0和1的貢獻度相等。

其實問題就轉變成了給定一個數組和一個target,要求我們能否從這些陣列當中選出一部分來求和之後等於target。我們之前在LeetCode當中做過這樣的題目,應該說是非常基礎了,只需要用遞迴就可以實現了。

但很遺憾的是,我把程式碼寫出來之後連樣例都過不了。錯在了這個樣例:

6 2
????00

由於最後出現了兩個0,所以對於最後一個視窗來說,是無論如何也是無法達成的。這個結論其實不難發現,觀察一下樣例就可以。

維護區間

發現了這個問題之後,於是我開始想辦法打補丁,也就是設計一種方法能夠解決這個問題。我於是想了一個辦法,對於每一個視窗我都維護兩個值。分別是應該賦值成1的?的數量和應該賦值成0的?數量,舉個例子,比如說還是剛才那個例子,一開始遇到兩個??,那麼顯然應該一個等於0一個等於1。

這樣當我們移動視窗的時候,會移出去一個字元,移進來一個字元。對於每個字元來說都有三種可能,所以一共就有9種可能。這9種情況我們也很容易想明白,首先移出和移入相等的情況,一定是合法的。如果移出的和移入不相等,並且當中沒有?的話,那麼一定是非法的。

如果移出0,移入?,那麼移入的?一定是0,也就是說確定是0的問號數量加一。如果移出的是1,那麼說明移入的?是1。如果移出的是?,移入的是1,說明移出的?也是1,也就是消耗了一個確定是1的?,同理如果移入的是0,也是一樣的。

這樣我們可以維護視窗內確定是0和確定是1的?的數量,在變化的過程當中,只要有一個小於0,那麼就說明情況是非法的,否則說明是合法的。

我原本以為這樣的方案應該已經很完美了,但是最後還是沒有AC。我仔細想了一下,其實這種方案還是存在漏洞,因為我們沒辦法判斷是否會出現前後矛盾的情況。也就是說最好要把每一個?的取值確定下來,而不是模稜兩可,因為模稜兩可就意味著可能存在矛盾。

正解

但是理論上來說每一個?都有兩種可能,我們怎麼能確定下來?的取值呢?

如果是單單思考這個問題是很難的,但其實我們剛才已經距離正解非常接近了,因為我們在維護區間的時候發現了一個非常重要的特性。就是當我們移動視窗的時候,移出的字元必須和移入的一致,否則一定非法。而我們移動的視窗的長度是確定的,我們就可以得到一個性質: s[i] = s[i+k]。

我們看下上圖,上圖框起來的k個元素代表視窗,當我們視窗移動的時候會移入一個元素,也會移出一個元素。我們假設目前視窗內的元素是合法的,也就是0和1一樣多。那麼當我們移動之後如果也是合法的,必須要保證移入的和移出的元素一樣,或者其中有一個是?。

我們進一步觀察會發現i和i + k,它們關於k同餘。說白了就是它們對k取模的餘數一樣,我們把所有關於k取模之後餘數一樣的數的集合稱為剩餘系。k的剩餘系一共有k個,這個也很容易想明白,因為k的餘數一共有0到k-1這k個。不管我們怎麼移動視窗,視窗內的元素都是k個,並且是每一個剩餘系各包含一個元素。所以我們可以檢查每一個剩餘系對應下標的元素是否全部相等或者是等於?,如果不滿足那麼一定非法。

檢查完所有的剩餘系之後,我們還要統計一下為0的剩餘系以及為1的剩餘系的數量。如果超過k的一半,那麼也一定是非法的。如果你能把這些點全部想明白,那麼這題的程式碼也就非常簡單了。

t = int(input())

for _ in range(t):
    n, k = list(map(int, input().split(' ')))
    st = input()
    if k % 2 == 1:
        print('NO')
        continue
    
    zero, one = 0, 0
    flag = True
    # 檢查所有剩餘系
    # 列舉對k取模之後的餘數
    for i in range(k):
        # tmp存這個剩餘系應該全部相等的字元
        tmp = None
        for j in range(i, n, k):
            if st[j] != '?':
                # 如果tmp是1遇到了0或者是tmp是0遇到了1
                if tmp is not None and st[j] != tmp:
                    flag = False
                    break
                tmp = st[j]
        if not flag:
            break
            
        # 根據tmp判斷是全部為0的剩餘系+1,還是全部為1的剩餘系+1
        if tmp == '0':
            zero += 1
        elif tmp == '1':
            one += 1

    # 有一種剩餘系的數量超過一半,那麼一定無法構成平衡
    if max(one, zero) > (k // 2):
        flag = False

    print('YES' if flag else 'NO')

我覺得今天的題挺難的,解題的思路繞了好幾個彎。從一開始的分析問題到後面嘗試解決,發現踩了坑,再繼續分析,繼續踩坑,最後發現了關鍵線索從而解出了問題。在問題解決之前百思不得其解是很痛苦的,但是想到了解法之後的成就感還是很令人欣喜的。我們做演算法題鍛鍊自己的能力,其實就是在這兩種體驗之間來回搖擺,在這過程當中獲得成長。從這個角度來說這題的質量的確很高,是我個人認為的高質量演算法題。

希望大家都能享受演算法題的快樂,祝大家週末愉快。

衷心祝願大家每天都有所收穫。如果還喜歡今天的內容的話,請來一個三連支援吧~(點贊、關注、轉發)

原文連結,求個關注

本文使用 mdnice 排版

- END -
{{uploading-image-692975.png(uploadi