# Python-LeetCode 581 第六招 Counter
「小皮球,香蕉油,滿地開花二十一,二五六,二五七,二八二九三十一」,數數。
本單元中我們說明使用計數器(counter)來記錄數量的基本操作以及LeetCode中常見的題型。
## 基本操作
通常有兩種方式來計算數量:表格(list)與字典(dict)。當計算對象是小範圍的整數時,可以用list來做;如果對象是範圍很廣(很稀疏)的範圍,或者是字串之類不容易轉成index來代表時,用dict是比較好的方式。Python的dict好用又快,所以往往可以用list來做的也用dict來做。
### 表格計數
用list或陣列來做表格計數是一種很基本的程式操作,在學習基本程式時經常都會做過一些練習,所以這一類很多題目的難度並不高。
最直接的使用是計算對象是小範圍的整數,尤其像是$[0,100]$或$[1,1000]$這種區間。初始時,我們先開一個list,初值給0,碰到某對象$p$,就將對應位置+1,如果要扣除就-1。例如
```python=
cnt = [0]*101
for p in nums:
cnt[p] += 1
# do something
cnt[p] -= 1
```
Python的list初始常用上面這樣的方式,乘法是表示重複,list的大小要小心,因為是從0開始編號,如果要做到100,大小需要到101。二維陣列的初始要小心,下面是常用的方式
```python=
cnt = [[0]*101 for i in range(101)]
for p,q in nums:
cnt[p][q] += 1
# do something
```
不可以使用
```python
cnt = [[0]*101]*101
```
的方式,因為把一個list $*101$是重複該物件101次,裡面的每一個list都是同一個,而非獨立不同的101個。
如果對象不是從0開始,那麼可以將它做一個平移轉換,例如常用的字元。英文字母或數字字元的ASCII都是連續的,數字字元是0x30 ~ 0x39、大寫字母是0x41 ~ 0x5A、小寫字母是0x61 ~ 0x7A。如果只有小寫字母(LeetCode這樣的題很多),我們可以用長度26的list來計。用ord()可以轉換為ASCII,例如下面的手法將'a' ~ 'z'轉換到0 ~ 25。
```python=
cnt = [0]*26
for ch in s: # char in string
cnt[ord(ch)-0x61] += 1
# do something
```
### 字典計數
Python的字典是hash table,使用方便,存取也快。基本的操作請讀者自行參考[官方dict說明文件](https://docs.python.org/zh-tw/3/library/stdtypes.html?highlight=dict#dict),網路上也找得到很多基本教學。我們這裡就不再重複。
文件中的操作與運算很多,以下我們列舉一些解題比較常用到的。
#### 基本dict以及常用運算與函數
* 初始化(有多種方式)
* 檢查key在不在。key in d, key not in d
* 單一key加入或改變與存取:d[key] = val、d[key]+=1、d[key] -= x、p = d[key]
* 刪除key值:del d[key]
* 取出字典內容(例如轉到一個list):list(d.items())。或取出dict所有的對應值(例如取最大):max(d.values())
* 兩個字典合併
#### defaultdict與Counter
使用dict有件討厭的事,就是若key值不存在時,取用會噴發KeyError的執行錯誤。因此有個衍生的字典類型稱為defaultdict [(說明文件)](https://docs.python.org/zh-tw/3/library/collections.html#collections.defaultdict),它的差別是你可指定在key值不存在時呼叫的函數來產生預設值。常用的是
defaultdict(int)
defaultdict(list)
defaultdict(set)
請參考上述說明文件中的範例就很容易了解基本的使用方式。
Python有一個常用的dict稱為Counter [(說明文件)](https://docs.python.org/zh-tw/3/library/collections.html#collections.Counter),基本上很類似於defaultdict(int),就是對應欄位是數字的字典,但它有一些擴充的函數很好用,請參考上述說明文件。
## 範例
### 基本範例
#### 統計數字出現次數
[(題目連結) 347. Top K Frequent Elements (Medium)](https://leetcode.com/problems/top-k-frequent-elements/)
題目很簡單,給一個數列以及$k$,找出出現次數最多的$k$個數字。
第一個重點是統計出每個數字出現的次數,我們用字典來做,第二個重點是找出出現次數的前$k$大。我們以下提供三個寫法。用哪一個就看讀者對Python字典的熟悉程度。
1. 使用Counter().most_common()
2. 使用Counter()與sort
3. 只使用基本的字典
以下是範例程式。裡面用到的Counter().most_common()、dict.items()與sort()的使用方法皆可於Python的線上說明文件中查到。
```python=
class Solution:
def topKFrequent(self, nums: List[int], k: int) -> List[int]:
'''
method 1, oneliner
#return [p[0] for p in Counter(nums).most_common(k)]
# mthod 2: using Counter() + sort
cnt = Counter(nums)
s = sorted([(cnt[p],p) for p in cnt],reverse=True)
return [p[1] for p in s[:k]]
'''
# method 3: using plain dict
cnt = {}
for p in nums:
if p in cnt: cnt[p] += 1
else: cnt[p] = 1
# dict.items = [(key,value)]
s = sorted(cnt.items(), key=lambda x:x[1],reverse=True)
return [p[0] for p in s[:k]]
```
三種方法的速度差不多。
#### 字串的範例
下面這一題與前一題很類似,但統計的對象是字串。
[(題目連結) 692. Top K Frequent Words (Medium)](https://leetcode.com/problems/top-k-frequent-words/)
題目說有一個字串陣列,要找出出現最多的前$k$個字串,並依照出現次數有多到少排列,相同次數者依照字典順序由小到大。
這題可以用上題類似的方法建立字典來統計次數,然後做排序找出前$k$個需要的字串。但這題的題目尾端有希望能在$O(n\log k)$的時間完成的需求。所以我們不使用排序(時間複雜度$O(n\log n)$而用heap來做。此外這題有個稍微麻煩的是排列順序,我們必須先用次數當主要的key,當次數一樣時,用字串的字典順序。Python的heap是min-heap,我們在heap中要的是讓字典序比較大的浮出以便丟棄,所以要用點技巧讓字典序反序。
在下面的範例程式中,我們是對每一個字串$w$用一個list作為heap中的比較key值,第一個欄位給的是出現次數,接下來的欄位用的是$128-ord(c)$,其中$c$是字串$w$的每一個字元,最後再接一個常數128。因為字元的ord(ASCII code)取負號就類似用補數,會將大小反序,另外尾端用128的是讓比較長的字串先浮出來,因為字元的ASCII比128要小。
```python=
class Solution:
def topKFrequent(self, words: List[str], k: int) -> List[str]:
c = Counter(words)
pq = []
for w in c:
heappush(pq,[c[w]]+[128-ord(c) for c in w]+[128])
if len(pq)>k: heappop(pq)
ans = []
while pq:
w = heappop(pq)
ans.append("".join([chr(128-i) for i in w[1:-1]]))
return ans[::-1]
```
第3行我們先把輸入的字串陣列丟進Counter()去統計各自串的出現次數。然後把Counter中的字串一一丟進pq中,用來比較的key是前面說的list。當heap內字串數量$>k$時會把key值最小的丟棄。
第8行開始準備要輸出的答案。我們把pq內的$k$個字串還原並且反轉順序就是要求的答案。將字串由我們定義的list $w$還原,我們用字串函數
$string.join(list)$
此函數將list中的每一個字串(或字元)以string當作連接字元加以連接成為一個字串。這裡我們的list用
[chr(128-i) for i in w[1:-1]]
因為最前面與最後面的不是字串內容,而字串內容是ASCII為128-i的字元。
時間複雜度$O(n\log k)$,因為$n$個字串進出heap各一次,此處字串長度看成constant。如果時間複雜度是要求$O(n\log n)$的話,可以用排序,會簡單一些。
#### 二維的例子以及Equivalence class
這題是Easy的題目,但算是典型而有趣的Counting問題,我們把它選進來。
[(題目連結)1128. Number of Equivalent Domino Pairs (Easy)](https://leetcode.com/problems/number-of-equivalent-domino-pairs/)
題目是說有一些骨牌(上下各有一個數字的長方形牌),請問有**幾對相同的骨牌**?每張骨牌以一對數字$[a,b]$表示,但骨牌可以上下反轉視為相同,也就是$[a,b]$與$[b,a]$是相同骨牌。
這可以看成一個簡單的分類的問題,我們知道如果同一類的東西有$k$個,則就是有$C(k,2)=k\times(k-1)/2$對相同的物品。在處理分類時,最簡單的方式就是定義每一類的**標準型**,將每一個物品轉換成標準型之後就容易歸類了。
本題每一個骨牌有兩個數字,可以交換。所以我們可以定義標準型是**先小後大**。
以下的範例程式我們還是用Counter(),這題也可用二維陣列來記錄,因為數字的範圍只有$[1,9]$。在歸類後每一類的數量可以用dict.values()的函數取出所有的對應值。
```python=
class Solution:
def numEquivDominoPairs(self, dominoes: List[List[int]]) -> int:
cnt = Counter([(min(a,b),max(a,b)) for a,b in dominoes])
total = 0
for v in cnt.values():
total += v*(v-1)//2
return total
```
以下是二維陣列來計數的範例程式。請有興趣的讀者自行參考,就不多贅述了。
```python=
class Solution:
def numEquivDominoPairs(self, dominoes: List[List[int]]) -> int:
# using table
cnt = [[0]*10 for i in range(10)]
for a,b in dominoes:
cnt[a][b] += 1
total = 0
for i in range(1,10):
total += cnt[i][i]*(cnt[i][i]-1)//2
for j in range(i+1,10):
v = cnt[i][j] + cnt[j][i]
total += v*(v-1)//2
return total
```
### 從值域下手
以下這題動一點腦筋之後應該算簡單。
[(題目連結) 299. Bulls and Cows (Medium)](https://leetcode.com/problems/bulls-and-cows/)
題目是常玩的幾A幾B的猜數字遊戲,有兩個長度相同的數字,一個數字是$secret$,另外一個數字$guess$。如果所猜數字與謎底有一個位數位置與數字皆相同,則有一個A;如果數字相同但位置不同,則有一個B,要計算此次猜的數字結果是幾A幾B。計算B的時候不重複計算,例如在給的範例
Input: secret = "1123", guess = "0111"
Output: "1A1B"
輸入的數字可能達到1000位,以字串的方式給予。
這題目要算A是很容易的,但要算B的時候要動一點腦筋,因為數字很長,如果一一比對的話,會達到$1000\times 1000$個字元比較。
因為每一位數字只會是0 ~ 9,值域很小。
值域很小時可以在值域上動腦筋。
我們計算secret中0出現的次數$count1[0]$。
計算guess中0出現的次數$count2[0]$。
那麼,$\min(count1[0],count2[0])$就是有幾個0猜對了,對0 ~ 9每個數字當作相同的處理就可以了。當然這包含了位置也對的A,所以最後要再把算在A中的扣回來就可以了。
以下是範例程式。第3行計算有幾個A,用zip枚舉兩序列的相同位置的對應字元,把邏輯式放在sum()裡面,True 會轉成1而False 就是0。接著計算每個數字字元出現的次數,這裡我們用表格(list)當作計數器,數字字元s取$ord(s)-0x30$就會轉換到0 ~ 9。程式很簡短,時間複雜度$O(n)$。
```python=
class Solution:
def getHint(self, secret: str, guess: str) -> str:
x = sum(s==t for s,t in zip(secret,guess))
count1 = [0]*10
for s in secret:
count1[ord(s)-0x30] += 1
count2 = [0]*10
for s in guess:
count2[ord(s)-0x30] += 1
y = sum(min(s,t) for s,t in zip(count1,count2)) - x
return str(x)+'A'+str(y)+'B'
```
#### Counting char
在字串中計算字元出現次數是常見的題型。以下是一例。
[(題目連結)1347. Minimum Number of Steps to Make Two Strings Anagram (Medium)](https://leetcode.com/problems/minimum-number-of-steps-to-make-two-strings-anagram/)
題目是輸入兩個長度相同的字串$s$與$t$,每一步你可以任選$t$的一個字元將其更換為任何其它字元,請問最少要幾步可以將$t$變成一個$s$的anagram,所謂字串的anagram是指所含字母相同但排列可能不同的字串。
這一題跟前面一題很類似,只是對象從數字換成了字元。我們不必去兩個字串中相互比對,而是應該統計每個字串中字元出現的次數。
我們先計算每一個字母出現次數的差值。
接下來有兩個方法都可以:一是去累加「$s$中出現次數比$t$中出現數多」的字母頻率的差值,像下面程式中第8行。因為兩字串長度相同,$s$中較多的差值總和一定剛好等於較少的差值總和,每一步可以將$t$中較多的一個字母換成$t$中一個較少的字母。所以最少的步驟只要計算較多(或較少)的差值總和就是答案。
另外一個方式是算每個字母差值的絕對值總和,最後除以2,像第6 ~ 7行那樣,道理與前面是一樣的。
以下是範例程式,此處我們用list來做計數器,因為字元只會是小寫字母,所以用長度26就夠了,對於字元$c$,用$ord(c)-0x61$就可以將結果對應到0 ~ 25的位置。
時間複雜度是$O(n)$。
```python=
class Solution:
def minSteps(self, s: str, t: str) -> int:
cnt1,cnt2=[0]*26,[0]*26
for c in s: cnt1[ord(c)-0x61] += 1
for c in t: cnt2[ord(c)-0x61] += 1
#total = sum(abs(p-q) for p,q in zip(cnt1,cnt2))
#return total//2
return sum(p-q for p,q in zip(cnt1,cnt2) if p>q)
```
接下來再看一個字元計算的題目。
[(題目連結) 1400. Construct K Palindrome Strings (Medium)](https://leetcode.com/problems/construct-k-palindrome-strings/)
本題中,輸入一個字串與一個整數$k$,請問你可否用這些字母做出$k$個迴文(palindrome)。所謂palindrome是一個左右對稱的字串,也就是由前往後讀與由後往前讀是一樣的,例如aba與abba。
迴文在LeetCode的字串題中出現非常多,但這裡只要知道**一個Palindrome中最多只有一個出現奇數次的字母**就可以了,這其實是**充分且必要條件**,一群字母,只要奇數次數的字母只有一個或沒有,必然可重排成為一個palindrome。
因此本題只要計算字母出現的次數就可以了。在$k$個回文的情形下,我們最多只能有$k$個字母出現奇數次,如果超過$k$個字母出現奇數次,就一定無法建構,如果少於$k$個是可以的,但是有個小陷阱,所給字串的長度必須至少為$k$。以下是範例程式,時間複雜度$O(n)$。
```python=
class Solution:
# at most k odd, len>=k
def canConstruct(self, s: str, k: int) -> bool:
cnt = Counter(s)
odd = 0
for c in cnt:
if cnt[c]&1: odd += 1
return odd<=k and len(s)>=k
```
### 邊走邊算 Sweeping and Counting
接下來看需要在掃描過程中邊走邊紀錄個數的題目。
[(題目連結) 1419. Minimum Number of Frogs Croaking (Medium)](https://leetcode.com/problems/minimum-number-of-frogs-croaking/)
本題定義,一隻青蛙叫是「依序出現c,r,o,a,k五個字母」,多隻青蛙的叫聲可能相互混雜,但每隻青蛙的五個字母仍然保持其順序。一隻青蛙在叫完一次croak之後可能再開始叫一次croak。輸入一個字串,請問最少有幾隻青蛙,或者不是一個合法的青蛙叫聲字串。
因為有不合法的字串而且一隻青蛙叫完一輪可能再叫一輪,所以不能只是算一下長度,或者只算一下每種字母出現的次數。對於一個合於規定的字串,其切割的方法並不唯一,簡單的例子ccrrooaakk就有$2^4=16$種切割的方式,但這不是本題的重點,我們只需要判斷是否合法以及在合法的情形下最少有幾隻青蛙。
這題有Greedy的味道,但其實是很直覺的:「當一隻青蛙結束一輪croak之後,下一個出現的c就計算為已經結束叫聲的青蛙的再一次叫聲開始」。所以最小值的問題是看這些混合字串的「厚度」。
假設這個字串是合法的,我們只抓第一個c與最後一個字母k。從前往後掃的過程中,每當一個c進入時,就多了一隻青蛙,每當一個k出現時,就減少一隻青蛙。在任何一個時刻的青蛙數的最大值,就是最少的青蛙數。
至於在判斷不合法的字串方面:「如果是一個合法的字串,每一個r會屬於它前方的某一個c,而每一個c只能有一個r跟他同一組。同樣的道理對接續的o,a,k都是一樣。」以下說明的本題演算法。
1. 我們對於c,r,o,a,k五個字母各用一個計數器紀錄目前尚未找到下一個字母的個數。定義c,r,o,a,k五個字母的順序分別是0 ~ 4。用變數num紀錄同時在線的青蛙數量,ans則是目前為止同時在線數量的最大值。
2. 由前往後掃描字串,如果碰到c,則cnt[c]+1;如果碰到其它字母,檢查順序在它前面那個字母的計數器,如果是0,則判斷為不合法,因為沒有前一個可以跟;如果不是0,則將前一個字母的計數器減一,自己的計數器加一。
3. 掃描過程中,碰到c則num+1,並檢查是否要更新ans;如果碰到k則num-1。
4. 到字串結尾時,除了k以外的所有的計數器必須都是0,否則不合法。
以下是範例程式,一開始我們用字典定義了五個字母的順序,然後開五個計數器,這是方便後面判斷字母順序之用。時間複雜度$O(n)$。
```python=
class Solution:
# status of a frog: 0~3 -> croa
def minNumberOfFrogs(self, croakOfFrogs: str) -> int:
d = {'c':0, 'r':1,'o':2,'a':3,'k':4}
cnt = [0]*5
ans = num = 0
for ch in croakOfFrogs:
if ch=='c': # new frog
cnt[0] += 1
num += 1
if num>ans: ans=num
else:
t = d[ch]
if cnt[t-1]==0: return -1
cnt[t-1] -= 1
cnt[t] += 1
if t==4: num -= 1
for i in range(4):
if cnt[i]: return -1
return ans
```
### Sliding window and Couting
[(題目連結) 992. Subarrays with K Different Integers (hard)](https://leetcode.com/problems/subarrays-with-k-different-integers/)
這個題目是說有一整數序列以及一個整數$k$,要計算有多少個子陣列包含恰好$k$個不同的數字。
這看起來是個sliding window的題目,搭配計算視窗內有多少個不同數字的技巧。當右端往右滑動時,視窗內的數字種類可能增加,所以我們必須滑動左端來減少視窗內數字種類。與基本滑動視窗不同的是,同一個右端可能可以對應很多個左端,例如$k=3$時,
$[0,1,2,1,1,2,1,2,3]$
對於右端在最後一個位置時,index 1 ~ 6都是合法的左端。
對一個固定右端,視窗的範圍越大,它所包含的不同數字種類顯然是**單調非遞減**的,也就是合法(恰含$k$個不同數字)的左端會是**一個連續區間**。
因此我們可以維護所有合法左端的區間上下界,當右端移動時,只要依照上下界的定義滑動左端上下界就可以了。此外,對於一個固定右端,它的合法視窗數就是左端上下界的差值。
以下的範例程式中,我們用list當計數器,並且要維護好目前視窗內不同數字的個數,因為有上下界,所以維護的指令都有兩套。寫法上可以先找出第一個合法視窗,再開始一步一步推動右端。這裡的寫法是從一開始就逐步滑動右端,用一個if(第31行)來檢查是否已經到達第一個合法視窗。
當右端滑動時,我們對左端的上下界基本上是做非常類似的操作。
1. 先將新進入的右端數字計數器加一
2. 如果是新數字,則個數加一
3. 當數字個數太多時,持續移動左端並記錄檢查計數器。對於下界來說,視窗內的個數必須$=k$;對上界來說,視窗內的個數必須$<k$(端點不含)。
時間複雜度都是$O(n)$。計數器用字典或Counter來做也是很合適的。因為這題數字的範圍是$[1,n]$,所以用list來做也很適合。
```python=
class Solution:
# sliding windows
# for each right, maintain lower and upper obound (smallest and largest)
# left boundary, such that the windows contains k different int
def subarraysWithKDistinct(self, nums: List[int], k: int) -> int:
n = len(nums)
low,up = 0,0
lowcnt,upcnt = [0]*(n+1), [0]*(n+1)
low_d = up_d = 0 # num of different
ans = 0
for right,p in enumerate(nums):
# update low
lowcnt[p] += 1
if lowcnt[p] == 1: low_d += 1 # new
while low_d > k:
q = nums[low]
lowcnt[q] -= 1
if lowcnt[q] == 0:
low_d -= 1
low += 1
# update up
upcnt[p] += 1
if upcnt[p] == 1: up_d += 1 # new
while up_d >= k:
q = nums[up]
upcnt[q] -= 1
if upcnt[q] == 0:
up_d -= 1
up += 1
#
if low_d==k: ans += up-low
# end for right
return ans
```
### Couting on a tree
[(題目連結)1519. Number of Nodes in the Sub-Tree With the Same Label (Medium)](https://leetcode.com/problems/number-of-nodes-in-the-sub-tree-with-the-same-label/)
輸入一個rooted tree,每一個節點有一個英文字母的label,要計算以每一點為root的subtree中有多少點的label與該點相同。
我們做一個DFS的走訪來歷遍所有的點,用一個計數器邊走邊累加紀錄每一個字元出現的總次數。根據DSF的特性,進入一點$v$後,會歷遍它的所有後代節點,然後離開$v$,因此我們所求的答案就是**進入與離開$v$點時,$v$的字元標籤計數器的差值**。
根據儲存的資料結構的不同,樹狀圖的DFS寫法有幾種,本題樹狀圖輸入是以無根樹的方式輸入邊集合,然後指定了0是根。所以我們先將邊的集合轉換成adjacent list,也就是每一點用一個list紀錄其鄰居的編號。以下是範例程式。
第3 ~ 6行做輸入轉換,這是常用寫法,第7行準備一個放答案的ans,第8行準備一個計數器,這裡用Counter來做,這題用長度26的list也可以,因為label都是小寫字母。
接著編寫一個dfs($v$,$p$)的函數,留意這裡樹狀圖的DFS寫法,我們傳入拜訪的點$v$以及它的parent $p$,在拜訪時,對該點除了parent的所有鄰居遞迴進行DFS,如第11 ~ 13行。因為樹狀圖沒有環路,所以每個點只要不呼叫它的parent,就不會重複走訪,因此沒有必要紀錄是否已經走過。
進入每一點時紀錄該點標籤字元計數器的值(第10行),離開時將該計數器的差值存入答案(第15行),其中第14行做加一是計入$v$本身。主程式呼叫時以根結點0進入,此時的根給-1代表沒有parent,所以用任何只要不是根的鄰居的假編號都可以。
時間複雜度$O(n)$,因為樹狀圖的邊數比點數少一。
```python=
class Solution:
def countSubTrees(self, n: int, edges: List[List[int]], labels: str) -> List[int]:
adj = [[] for i in range(n)]
for u,v in edges:
adj[u].append(v)
adj[v].append(u)
ans = [0]*n
cnt= Counter()
def dfs(v,p): # visit v, parent p
out = cnt[labels[v]]
for u in adj[v]:
if u != p:
dfs(u,v)
cnt[labels[v]] += 1
ans[v] = cnt[labels[v]] - out
dfs(0,-1)
return ans
```
## 結語
計數器不是一個難的東西,LeetCode中標籤在*counting*的題目大多也相對較簡單,很多是屬於Easy與Medium的困難度。
本單元介紹了一些使用字典或list來做計數器的題目與常用技巧,包含一維與二維的數字、字元與字串。當然很多題目會用到多項技巧的合併,本單元中我們也介紹了計數器結合Sweeping、Sliding window、或Tree DFS的題目。Sliding window在前面的單元介紹過了([Python-LeetCode 第二招 Sliding Window](/Yt6Zukv-SSSYFOKonWq8Bw)),有關Tree與DFS後面還會有單元做詳細的介紹。