KajoData / Python
Jak policzyć elementy listy w Pythonie: dict, Counter i hash
Pokazuję, jak z listy zrobić tabelę częstości i sprawdzić, czy liczymy właściwe rzeczy. Wyjaśniam klucze, remisy, brakujące kategorie i pułapkę True oraz 1.

Pytanie „ile elementów jest na liście?” może oznaczać kilka różnych rzeczy. Czasem chcę znać długość listy. Czasem liczbę wystąpień jednej wartości. Innym razem potrzebuję pełnej tabeli częstości: każda kategoria i liczba rekordów, które do niej należą. Zanim wybiorę narzędzie, ustalam właśnie to pytanie. W przeciwnym razie bardzo łatwo poprawnie policzyć coś innego, niż oczekuje odbiorca.
Pokażę zwykły słownik i Counter z biblioteki standardowej. Przykłady są syntetyczne, sprawdzone w Pythonie 3.14.4 i nie wymagają instalowania pandas. Każdy blok definiuje swoje dane i działa osobno. Będziemy liczyć kanały kontaktu, a później sprawdzimy brakujące wartości, klucze złożone, remisy i przypadki, w których pozornie różne elementy trafiają do jednej kategorii.
Długość, jedno wystąpienie i liczba różnych wartości
Zacznijmy od sześciu zapisów: trzech kontaktów przez e-mail, dwóch przez czat i jednego przez telefon. Lista reprezentuje tutaj kontakty, nie osoby. Jeśli jedna osoba napisała trzy razy, ma trzy rekordy. Odpowiedź na pytanie o liczbę unikalnych osób wymagałaby identyfikatora osoby, którego ta lista nie zawiera. Żadna metoda zliczająca nie odtworzy go z nazwy kanału.
# example: questions
from collections import Counter
channels = ["email", "chat", "email", "phone", "email", "chat"]
counts = Counter(channels)
print(len(channels), channels.count("email"), len(counts))
print(dict(counts), counts.total())
Pierwszy wynik to 6 3 3: sześć elementów, trzy wystąpienia e-maila, trzy przechowywane klucze. Drugi to {'email': 3, 'chat': 2, 'phone': 1} i suma sześć. Counter jest klasą dziedziczącą po dict, a nie osobną funkcją wbudowaną dostępną bez importu. Tworzy obiekt wygodny do pracy z licznościami.
list.count jest czytelne, jeśli pytasz o jedną konkretną wartość. Jeśli dla każdej kategorii ponownie przeglądasz całą listę, wykonujesz niepotrzebne powtórzenia. Słownik albo Counter pozwala zbudować wszystkie liczności podczas jednego przejścia. Nie podaję tu obietnicy konkretnego przyspieszenia: wynik pomiaru zależy od danych i środowiska, a dla małej listy najważniejsza bywa czytelność.
Najpierw buduję licznik zwykłym słownikiem
Warto choć raz napisać zliczanie ręcznie. Klucz reprezentuje kategorię, a wartość liczbę napotkanych elementów. Przy każdym kolejnym wystąpieniu zwiększam licznik o jeden. Jeżeli klucza jeszcze nie ma, zaczynam od zera. Takie wyjaśnienie pomaga później odróżnić aktualizowanie liczności od nadpisywania gotowych wyników.
# example: dictionary
channels = ["email", "chat", "email", "phone", "email", "chat"]
counts = {}
for channel in channels:
counts[channel] = counts.get(channel, 0) + 1
print(counts)
print(sum(counts.values()) == len(channels))
Wynik zawiera te same trzy liczności, a kontrola daje True. Metoda get zwraca wartość domyślną zero, kiedy dany klucz nie istnieje. Samo get go nie dodaje; dodanie lub aktualizacja następuje w przypisaniu. Możesz napisać równoważną wersję z warunkiem if channel in counts, ale tutaj krótki zapis nadal jasno pokazuje wykonywaną operację.
Sprawdzenie sumy jest przydatne, bo każdy element tej listy ma trafić do dokładnie jednej kategorii. Nie stosuj go bez namysłu do danych, w których jeden rekord ma kilka tagów. Licząc tagi, możesz poprawnie otrzymać więcej wystąpień niż rekordów. Kontrola musi wynikać z definicji jednostki, którą liczysz, a nie tylko z wygodnego porównania dwóch liczb.
Counter przyjmuje elementy albo gotowe liczności
Konstruktor może dostać iterowalną kolekcję elementów lub mapowanie kluczy na liczności. To dwa różne rodzaje wejścia. Lista napisów oznacza pojedyncze wystąpienia. Słownik z liczbami oznacza już zagregowane wartości. Przekazanie samego napisu spowoduje zliczanie jego znaków, ponieważ napis również jest iterowalny. Ta właściwość jest wygodna, o ile rzeczywiście tego oczekujesz.
# example: inputs
from collections import Counter
print(dict(Counter("aba")))
print(dict(Counter(["aba"])))
counts = Counter({"email": 3, "chat": 2})
counts.update(["email", "phone"])
counts.update({"chat": 4})
print(dict(counts))
Wyniki to {'a': 2, 'b': 1}, {'aba': 1} i {'email': 4, 'chat': 6, 'phone': 1}. Counter.update dodaje liczności. Nie działa jak zwykłe dict.update, które zastąpiłoby dotychczasową wartość nową. W naszym przykładzie cztery kontakty przez czat zostały dodane do dwóch już policzonych, stąd sześć.
Opis konstruktora i metod znajdziesz w dokumentacji Counter. Przy praktycznym imporcie sprawdzam, czy wejście jest strumieniem zdarzeń, czy tabelą wcześniej obliczonych liczności. Pomylenie tych form może dać wynik o poprawnym typie i całkowicie błędnym znaczeniu. Szczególnie łatwo o to, gdy obie struktury mają podobne nazwy zmiennych.
Co naprawdę oznacza hashowalny klucz
Słownik potrzebuje kluczy, dla których dostępny jest stabilny hash oraz zgodne zasady równości. Wbudowane napisy i liczby nadają się do tego celu. Wbudowane listy i słowniki nie są hashowalne. Krotka nadaje się tylko wtedy, gdy jej elementy także są hashowalne. Zamiana listy na krotkę nie rozwiąże więc problemu, jeżeli w środku nadal znajduje się kolejna lista.
# example: keys
from collections import Counter
pairs = [("email", "open"), ("chat", "closed"), ("email", "open")]
print(dict(Counter(pairs)))
for value in ([1, 2], {"id": 1}, ([1, 2],)):
try:
hash(value)
except TypeError:
print("unhashable")
Pierwsza para występuje dwa razy, druga raz. Trzy kolejne sprawdzenia wypiszą unhashable. Hash nie jest unikalnym identyfikatorem rekordu: różne klucze mogą mieć ten sam hash, a słownik dodatkowo sprawdza ich równość. Nie zastępuję zatem identyfikatorów wynikiem hash() i nie używam go jako trwałego klucza między niezależnymi uruchomieniami programu.
Stara wersja tekstu zaliczała ogólnie „obiekty klas” do niehashowalnych. To zbyt szerokie stwierdzenie. Instancje własnej klasy mogą być hashowalne; zależy to od definicji równości i __hash__. Model danych Pythona wyjaśnia te reguły, a słownik pojęć podaje ich sens. W zwykłym raporcie najczęściej wybieram prosty, jawny klucz tekstowy lub krotkę pól.
Najpierw wybieram, co uznaję za tę samą kategorię
"Email", "email" i " email " są różnymi napisami. Jeżeli w źródle oznaczają ten sam kanał, potrzebuję reguły normalizacji. Jeśli wielkość liter jest częścią identyfikatora, taka zmiana mogłaby zniszczyć informację. Dlatego czyszczenie poprzedzam ustaleniem znaczenia pola, a surową wersję zostawiam dostępną do porównania.
# example: normalization
from collections import Counter
raw = ["Email", "email", " email ", "Chat", None, ""]
normalized = []
missing = 0
for value in raw:
if value is None or value.strip() == "":
missing += 1
else:
normalized.append(value.strip().casefold())
counts = Counter(normalized)
print(dict(counts), missing)
print(counts.total() + missing == len(raw))
Wynik to {'email': 3, 'chat': 1} 2, a kontrola daje True. W tej próbce jawnie uznaję None i pusty tekst za braki. Nie dopisuję ich do liczności znanych kanałów. Zachowuję jednak ich liczbę, dzięki czemu raport nadal wyjaśnia wszystkie sześć rekordów. Funkcja casefold służy do porównań tekstowych bez rozróżniania wielkości liter; sama nie rozpoznaje synonimów ani błędów pisowni.
Przykład zakłada, że niepuste wartości są napisami. Jeśli źródło dopuszcza liczby lub inne typy, dodaj ich walidację, zamiast bezwarunkowo wykonywać str(value). Takie rzutowanie mogłoby zamienić rzeczywisty brak w napis i stworzyć fałszywą kategorię. Przy bardziej złożonym porządkowaniu kategorii przydaje się również pandas replace, ale reguły znaczenia nadal ustala człowiek.

True i 1 mogą trafić do jednego klucza
Python porównuje True, 1 i 1.0 jako równe wartości, a ich hashe są zgodne. W słowniku mogą więc reprezentować ten sam klucz. Jeżeli lista miesza typy, wynik zliczania może zaskoczyć. To powód, żeby kontrolować dane wejściowe, a nie traktować tabeli częstości jako neutralnego odbicia dowolnej kolekcji.
# example: mixed-types
from collections import Counter
values = [True, 1, 1.0, False, 0, "1"]
counts = Counter(values)
print(counts[True], counts[False], counts["1"], len(counts))
typed = Counter((type(value).__name__, value) for value in values)
print(len(typed), typed[("bool", True)], typed[("int", 1)])
Pierwszy wynik to 3 2 1 3, drugi 6 1 1. Klucz złożony z nazwy typu i wartości rozdziela sześć pozycji w tej konkretnej próbce. Nie jest to uniwersalny sposób porównywania wszystkich obiektów Pythona. Przy prostym polu raportowym zwykle lepiej wymusić jeden typ na wejściu, niż później tłumaczyć odbiorcy osobne kategorie liczbowej jedynki i logicznej prawdy.
Także None jest hashowalne i samo z siebie nie znika z Counter. Zdecyduj, czy ma zostać osobną kategorią braków, czy ma być raportowane obok wyniku. W danych z bibliotek numerycznych mogą wystąpić inne reprezentacje braków, dla których zwykłe reguły równości są mniej intuicyjne. Najpierw ujednolicam brak zgodnie z kontraktem, potem liczę - to znacznie prostszy proces do sprawdzenia.
Remisy i brakujące kategorie wymagają umowy raportu
most_common pozwala dostać najczęstsze elementy. Przy równych licznościach zachowuje kolejność pierwszego napotkania. To deterministyczne dla konkretnego wejścia, ale niekoniecznie dla zbioru przychodzącego w różnej kolejności. Jeśli raport wymaga alfabetycznego rozstrzygania remisów, zapisuję taką regułę osobno w sortowaniu.
# example: ranking
from collections import Counter
counts = Counter(["phone", "email", "chat", "email", "chat"])
print(counts.most_common(2))
print(sorted(counts.items(), key=lambda item: (-item[1], item[0])))
print(counts["web"], "web" in counts)
counts["web"] = 0
print(len(counts), dict(+counts))
Wynik top dwóch to [('email', 2), ('chat', 2)]. Jawne sortowanie da najpierw chat, potem email, a następnie phone. Odczyt brakującego klucza web zwróci zero i nie doda klucza, więc sprawdzenie obecności daje False. Dopiero przypisanie zera wprowadza go do licznika, zwiększając liczbę przechowywanych kluczy do czterech.
Jednoargumentowy plus tworzy licznik zawierający tylko dodatnie liczności. Przydaje się do czyszczenia wyniku, ale może usunąć kategorię, którą świadomie chcesz pokazać jako zero. Do stałego zestawu kategorii raportowych wolę osobną listę oczekiwanych nazw i odczyt liczności dla każdej. Wtedy kanał bez kontaktów nadal pojawi się w tabeli, nawet jeśli nie wystąpił w wejściu.
Odejmowanie może zachować albo usunąć wynik ujemny
Licznik może przechowywać zero i wartości ujemne. Są użyteczne na przykład przy porównaniu stanu oczekiwanego z obserwowanym, ale nie oznaczają zwykłej liczby wystąpień w surowej liście. Trzeba też odróżnić metodę subtract od operatora odejmowania dwóch liczników. Ich wyniki nie mają identycznych zasad zachowywania wartości niedodatnich.
# example: subtraction
from collections import Counter
expected = Counter({"email": 3, "chat": 1})
observed = Counter({"email": 1, "chat": 2})
signed = expected.copy()
signed.subtract(observed)
print(dict(signed), signed.total())
print(dict(expected - observed))
print(dict(+signed))
Wynik signed to {'email': 2, 'chat': -1}, z sumą jeden. Operator odejmowania zachowa tylko {'email': 2}, podobnie jak plus zastosowany do wyniku ze znakiem. Jeżeli minus jeden informuje o nadmiarze względem planu, jego usunięcie zmieni znaczenie raportu. Warto zdecydować, czy pokazujesz różnicę ze znakiem, czy wyłącznie dodatnie niedobory.
Nie używam wtedy len(counter) jako liczby zdarzeń. To liczba przechowywanych kluczy, także tych z zerem lub ujemną wartością. total() sumuje wartości i również może uwzględniać wartości ujemne. Obie operacje są poprawne, ale trzeba je podpisać zgodnie z tym, co reprezentują w danym etapie obliczeń.
Liczenie rekordów według wybranych pól
Lista słowników nie nadaje się bezpośrednio na klucze Counter. Zamiast zamieniać cały rekord na napis, wybieram pola definiujące grupę. Dla syntetycznych zgłoszeń może to być kanał i status. Pominięcie identyfikatora oznacza, że różne zgłoszenia w tej samej grupie mają być policzone razem. Włączenie identyfikatora dałoby inne pytanie i zwykle więcej grup.
# example: records
from collections import Counter
rows = [
{"id": "A", "channel": "email", "status": "open"},
{"id": "B", "channel": "email", "status": "open"},
{"id": "C", "channel": "chat", "status": "closed"},
]
counts = Counter((row["channel"], row["status"]) for row in rows)
print(dict(counts))
print(counts.total() == len(rows))
Otrzymasz dwie grupy z licznościami dwa i jeden oraz True. Brak wymaganego pola zgłosi KeyError, zamiast cicho nadać rekordowi przypadkową kategorię. Jeśli brak jest dopuszczalny, obsłuż go jawnie. Użycie get z domyślnym napisem ma sens dopiero wtedy, gdy ten napis nie zderzy się z prawdziwą wartością w danych.
Dla wielu kolumn, agregacji liczbowych i dalszego łączenia tabel wygodniejszy może być pandas albo SQL. Counter jest szczególnie czytelny, gdy masz iterowalne elementy i potrzebujesz samej liczności. Wybór narzędzia nie zmienia obowiązku określenia poziomu danych. Zanim policzę zgłoszenia, sprawdzam, czy rekordy nie są przypadkiem historią zmian statusów tych zgłoszeń.
Jak kontroluję wynik i zużycie pamięci
Dla zwykłej listy bez filtrowania suma liczności powinna zgadzać się z długością wejścia. Po odrzuceniu braków sprawdzam sumę liczności plus liczbę odrzuconych rekordów. Dla rankingu kontroluję pełny wynik przed wybraniem pierwszych kategorii: suma top trzech zwykle nie jest sumą całego zbioru. Taki szczegół potrafi zmienić mianownik wykresu procentowego.
Licznik można aktualizować partiami, co pozwala nie przechowywać wszystkich zdarzeń naraz. Nadal przechowuje jednak klucz dla każdej różnej wartości. Jeżeli niemal każdy rekord ma inny identyfikator, licznik także będzie duży. Strumieniowy odczyt ogranicza pamięć wejścia, ale nie usuwa kosztu przechowywania wszystkich odrębnych kategorii. Przy bardzo dużym zbiorze potrzebna może być agregacja w bazie.
Przy kolejnych partiach sprawdzam, czy ich zakresy się nie nakładają. update posłusznie doda wszystko, również rekordy wczytane drugi raz. Nie wykryje samodzielnie ponownego importu ani nie zapewni idempotencji. To samo dotyczy scalania gotowych liczników: zgodne klucze i poprawne sumowanie nie dowodzą, że agregaty obejmują rozłączne zdarzenia.
Przechowuję także kolejność etapów. Najpierw walidacja i uzgodniona normalizacja, potem zliczanie, następnie ranking i procenty. Zmiana reguł normalizacji po obliczeniu rankingu może połączyć kategorie i zmienić kolejność. Jeśli wynik ma służyć do porównania okresów, reguły powinny być spójne między okresami albo różnica musi być wyjaśniona odbiorcy.
Iterator może zostać odczytany tylko raz
Lista pozwala wracać do elementów, ale iterator przesuwa się podczas odczytu. Gdy Counter zużyje generator, drugie zliczenie tego samego generatora zobaczy pusty strumień. Taki wynik nie oznacza, że pierwsze obliczenie usunęło dane z pliku. Oznacza, że obiekt opisujący przebieg odczytu doszedł już do końca. Przy pracy z plikami to częsta przyczyna różnicy między dwoma pozornie identycznymi fragmentami kodu.
# example: iterator
from collections import Counter
events = (value for value in ["email", "chat", "email"])
first = Counter(events)
second = Counter(events)
print(first.total(), second.total())
Wynik to 3 0. Jeśli potrzebuję drugiego przejścia, tworzę nowy iterator ze źródła albo świadomie materializuję wejście jako listę. Ta druga opcja wymaga pamięci, więc nie wybieram jej automatycznie dla dużego eksportu. Można też zapisać potrzebne kontrole podczas pierwszego przejścia i uniknąć powtórnego odczytu.
Przy debugowaniu sprawdzam zatem nie tylko wartości, ale również rodzaj kolekcji oraz wcześniejsze operacje. Podgląd kilku elementów generatora też je zużywa. Dla nauki zaczynam od listy, na której łatwo porównać wyniki, a dopiero później zmieniam sposób dostarczania danych. Pozwala to oddzielić błąd w samym zliczaniu od błędu w przepływie wejścia.
Ćwiczenie: raport z zerem i brakami
Weź dane z przykładu normalizacji i przygotuj tabelę dla oczekiwanych kanałów email, chat, phone. Powinieneś otrzymać odpowiednio trzy, jeden i zero, a osobno dwa braki. Udział e-maila w znanych kanałach wyniesie trzy czwarte, czyli 75%. Udział e-maila we wszystkich sześciu rekordach wyniesie 50%. Zapisz, który mianownik odpowiada pytaniu raportu.
Następnie dodaj jeden kontakt przez telefon, jeden pusty napis i kategorię web. Sprawdź, czy pokazanie tylko oczekiwanych kanałów nie ukryło nowej kategorii. Możesz odrzucić nieznane wartości jako błąd kontraktu albo pokazać je osobno; nie pozwól, żeby zniknęły bez śladu. To dobry test, czy kod odpowiada za pełne rozliczenie wejścia, czy tylko za ładny wydruk.
Na końcu uruchom rozwiązanie dla pustej listy i listy samych braków. Liczności mogą być zerowe, ale procent z mianownikiem zero wymaga jawnego braku wyniku. Kiedy ten etap działa, połącz go z odczytem CSV i wybieraniem fragmentów listy. Masz wtedy mały proces, w którym potrafisz wyjaśnić zarówno każdą liczbę, jak i każdą pominiętą obserwację.
Najważniejsze w pigułce
Czym różni się len od Counter?
len podaje liczbę elementów kolekcji. Counter tworzy liczności dla poszczególnych hashowalnych wartości; len samego Counter oznacza liczbę przechowywanych kluczy.
Dlaczego Counter nie przyjmuje list jako kluczy?
Wbudowane listy nie są hashowalne. Zamiast całego rekordu wybieram stabilny klucz opisujący to, co chcę liczyć, na przykład krotkę dwóch pól tekstowych.
Czy Counter automatycznie ignoruje None?
Nie. None jest poprawnym kluczem i zostanie policzone. Braki trzeba zachować, odfiltrować albo raportować osobno zgodnie z definicją danych.
Przy pracy nad artykułem użyto w zautomatyzowany sposób sztucznej inteligencji. Jeśli też chcesz tak umieć, sprawdź ten projekt.
