PHP / Magento Dev Blog

  • Publikacje
  • O autorze
  • Kontakt

Trie – drzewo prefiksowe, autouzupełnianie, filtr spamu, benchmark vs array

by Henryk Tews / wtorek, 16 kwietnia 2024 / Opublikowano w Algorytmy

Trie (drzewo prefiksowe) to struktura danych zoptymalizowana pod wyszukiwanie słów i prefiksów. Zamiast porównywać każde słowo ze słownikiem, Trie przechodzi po literach – każdy węzeł to jedna litera, każda ścieżka od korzenia to słowo. Autouzupełnianie w wyszukiwarce, walidacja SKU, wykrywanie spamu – Trie pojawia się w e-commerce częściej niż myślisz. Implementuję od zera w PHP z praktycznymi przykładami.

Czym jest Trie?

Wyobraź sobie słownik słów: „cat”, „car”, „card”, „care”, „bat”. W zwykłej tablicy szukasz każdego słowa liniowo – O(n). W Trie każda litera to węzeł. Szukasz „car” – c → a → r – trzy kroki niezależnie od rozmiaru słownika. Złożoność: O(m) gdzie m = długość szukanego słowa.

Implementacja węzła i Trie

<?php

declare(strict_types=1);

class TrieNode
{
    /** @var array<string, TrieNode> */
    public array $children = [];
    public bool $isEndOfWord = false;
    public mixed $value = null; // opcjonalne dane powiązane ze słowem

    public function hasChild(string $char): bool
    {
        return isset($this->children[$char]);
    }

    public function getChild(string $char): ?TrieNode
    {
        return $this->children[$char] ?? null;
    }

    public function addChild(string $char): TrieNode
    {
        $this->children[$char] = new TrieNode();
        return $this->children[$char];
    }
}

class Trie
{
    private TrieNode $root;

    public function __construct()
    {
        $this->root = new TrieNode();
    }

    // Wstaw słowo - O(m) gdzie m = długość słowa
    public function insert(string $word, mixed $value = null): void
    {
        $node = $this->root;
        $word = mb_strtolower($word);

        for ($i = 0; $i < mb_strlen($word); $i++) {
            $char = mb_substr($word, $i, 1);

            if (!$node->hasChild($char)) {
                $node->addChild($char);
            }

            $node = $node->getChild($char);
        }

        $node->isEndOfWord = true;
        $node->value       = $value;
    }

    // Szukaj dokładnego dopasowania - O(m)
    public function search(string $word): bool
    {
        $node = $this->findNode(mb_strtolower($word));
        return $node !== null && $node->isEndOfWord;
    }

    // Sprawdź czy istnieje słowo zaczynające się od prefiksu - O(m)
    public function startsWith(string $prefix): bool
    {
        return $this->findNode(mb_strtolower($prefix)) !== null;
    }

    // Pobierz wartość powiązaną ze słowem
    public function getValue(string $word): mixed
    {
        $node = $this->findNode(mb_strtolower($word));
        return ($node && $node->isEndOfWord) ? $node->value : null;
    }

    // Znajdź wszystkie słowa z danym prefiksem - autouzupełnianie
    public function autocomplete(string $prefix, int $limit = 10): array
    {
        $prefix = mb_strtolower($prefix);
        $node   = $this->findNode($prefix);

        if ($node === null) {
            return [];
        }

        $results = [];
        $this->dfs($node, $prefix, $results, $limit);
        return $results;
    }

    private function findNode(string $word): ?TrieNode
    {
        $node = $this->root;

        for ($i = 0; $i < mb_strlen($word); $i++) {
            $char = mb_substr($word, $i, 1);

            if (!$node->hasChild($char)) {
                return null;
            }

            $node = $node->getChild($char);
        }

        return $node;
    }

    // DFS po drzewie - zbiera wszystkie słowa od danego węzła
    private function dfs(TrieNode $node, string $current, array &$results, int $limit): void
    {
        if (count($results) >= $limit) {
            return;
        }

        if ($node->isEndOfWord) {
            $results[] = ['word' => $current, 'value' => $node->value];
        }

        foreach ($node->children as $char => $child) {
            $this->dfs($child, $current . $char, $results, $limit);
        }
    }

    // Usuń słowo - O(m)
    public function delete(string $word): bool
    {
        return $this->deleteNode($this->root, mb_strtolower($word), 0);
    }

    private function deleteNode(TrieNode $node, string $word, int $depth): bool
    {
        if ($depth === mb_strlen($word)) {
            if (!$node->isEndOfWord) {
                return false;
            }
            $node->isEndOfWord = false;
            return empty($node->children);
        }

        $char = mb_substr($word, $depth, 1);

        if (!$node->hasChild($char)) {
            return false;
        }

        $shouldDeleteChild = $this->deleteNode($node->getChild($char), $word, $depth + 1);

        if ($shouldDeleteChild) {
            unset($node->children[$char]);
            return empty($node->children) && !$node->isEndOfWord;
        }

        return false;
    }
}

Autouzupełnianie wyszukiwarki produktów

<?php

declare(strict_types=1);

// Budowanie indeksu produktów w Trie
$searchTrie = new Trie();

$products = [
    ['id' => 1,  'sku' => 'WIDGET-PRO',   'name' => 'Widget Pro'],
    ['id' => 2,  'sku' => 'WIDGET-LITE',  'name' => 'Widget Lite'],
    ['id' => 3,  'sku' => 'GADGET-X500',  'name' => 'Gadget X500'],
    ['id' => 4,  'sku' => 'GADGET-X1000', 'name' => 'Gadget X1000'],
    ['id' => 5,  'sku' => 'CABLE-USB-C',  'name' => 'Kabel USB-C 2m'],
    ['id' => 6,  'sku' => 'CABLE-HDMI',   'name' => 'Kabel HDMI 4K'],
    ['id' => 7,  'sku' => 'PHONE-CASE-A', 'name' => 'Etui na telefon model A'],
    ['id' => 8,  'sku' => 'CHARGER-65W',  'name' => 'Ładowarka 65W GaN'],
];

// Indeksuj po nazwie i SKU
foreach ($products as $product) {
    // Indeksuj całą nazwę
    $searchTrie->insert($product['name'], $product);

    // Indeksuj każde słowo z nazwy osobno (dla wyszukiwania po słowie)
    foreach (explode(' ', $product['name']) as $word) {
        if (strlen($word) >= 3) {
            $searchTrie->insert($word, $product);
        }
    }

    // Indeksuj SKU
    $searchTrie->insert($product['sku'], $product);
}

// Autouzupełnianie - "wid" → sugeruje Widget Pro, Widget Lite
$suggestions = $searchTrie->autocomplete('wid', limit: 5);
foreach ($suggestions as $s) {
    echo "Sugestia: {$s['word']} (ID: {$s['value']['id']})\n";
}
// Sugestia: widget lite (ID: 2)
// Sugestia: widget pro (ID: 1)

// Szybkie sprawdzenie czy SKU istnieje - walidacja importu
$skusToCheck = ['WIDGET-PRO', 'WIDGET-XXX', 'CABLE-USB-C'];
foreach ($skusToCheck as $sku) {
    $exists = $searchTrie->search($sku);
    echo "{$sku}: " . ($exists ? 'istnieje' : 'nie istnieje') . "\n";
}

Trie jako filtr spamu – lista zakazanych słów

<?php

declare(strict_types=1);

class SpamFilter
{
    private Trie $trie;

    public function __construct(array $bannedWords)
    {
        $this->trie = new Trie();
        foreach ($bannedWords as $word) {
            $this->trie->insert($word, true);
        }
    }

    public function containsSpam(string $text): bool
    {
        $words = preg_split('/\s+/', mb_strtolower($text));

        foreach ($words as $word) {
            // Usuń interpunkcję
            $clean = preg_replace('/[^a-z0-9ąćęłńóśźż]/u', '', $word);

            if ($this->trie->search($clean)) {
                return true;
            }
        }

        return false;
    }

    public function censorText(string $text): string
    {
        $words = explode(' ', $text);

        return implode(' ', array_map(function(string $word) {
            $clean = preg_replace('/[^a-z0-9ąćęłńóśźż]/ui', '', mb_strtolower($word));

            if ($this->trie->search($clean)) {
                return str_repeat('*', mb_strlen($word));
            }

            return $word;
        }, $words));
    }
}

$filter = new SpamFilter(['spam', 'reklama', 'kliknij', 'wygraj']);
echo $filter->containsSpam('Kliknij tutaj żeby wygrać nagrodę') ? "SPAM\n" : "OK\n";
// SPAM

echo $filter->censorText('To jest reklama produktu');
// To jest ******* produktu

Porównanie wydajności – Trie vs in_array vs array_search

<?php

// Benchmark dla 100 000 SKU i 10 000 wyszukiwań

$skus = array_map(fn($i) => 'SKU-' . str_pad((string) $i, 8, '0', STR_PAD_LEFT), range(1, 100000));

// Trie - O(m) per wyszukiwanie
$trie = new Trie();
foreach ($skus as $sku) {
    $trie->insert($sku);
}

// Test wyszukiwania
$searchTerms = array_rand(array_flip($skus), 10000);

$start = microtime(true);
foreach ($searchTerms as $sku) {
    $trie->search($sku);
}
$trieTime = (microtime(true) - $start) * 1000;

// in_array - O(n) per wyszukiwanie
$start = microtime(true);
foreach ($searchTerms as $sku) {
    in_array($sku, $skus, true);
}
$inArrayTime = (microtime(true) - $start) * 1000;

// isset na tablicy asocjacyjnej - O(1) hash lookup
$skusMap = array_flip($skus);
$start = microtime(true);
foreach ($searchTerms as $sku) {
    isset($skusMap[$sku]);
}
$issetTime = (microtime(true) - $start) * 1000;

echo "Trie:      {$trieTime}ms\n";        // ~15ms
echo "in_array:  {$inArrayTime}ms\n";     // ~8000ms (O(n) per lookup!)
echo "isset:     {$issetTime}ms\n";        // ~2ms (hash table O(1))

// Wniosek: dla exact match - isset na tablicy asocjacyjnej jest najszybsze
// Trie błyszczy przy prefix search i autocomplete - czego isset nie oferuje

Kiedy Trie, kiedy inna struktura?

Zastosowanie Najlepsza struktura
Exact match wyszukiwanie Array + isset (hash table O(1))
Prefix search / autocomplete Trie O(m)
Wildcard search (*pattern*) Elasticsearch / baza danych LIKE
Fuzzy search (literówki) Elasticsearch, BK-tree
Wykrywanie słów w tekście Trie (Aho-Corasick variant)

Podsumowanie

Trie to wyspecjalizowana struktura danych która błyszczy przy prefix search i autouzupełnianiu. W PHP masz do dyspozycji gotowe rozwiązania jak Elasticsearch dla wyszukiwania pełnotekstowego – Trie warto znać gdy potrzebujesz lekką, in-process strukturę bez zewnętrznych zależności. Klasyczny przykład użycia: walidacja setek tysięcy SKU podczas importu lub dynamiczne autouzupełnianie bez trafienia do bazy przy każdym keystroke.

About Henryk Tews

Co możesz przeczytać następne

Lista dwukierunkowa – implementacja od zera, SplDoublyLinkedList, tabela porównawcza
Sortowanie w PHP – bubble sort, merge sort, usort() i operator <=>
Bloom Filter – probabilistyczna struktura, blacklisty tokenów, negative cache
  • Publikacje
  • O autorze
  • Kontakt

© 2026 Created by

GÓRA
Zarządzaj zgodą
Aby zapewnić jak najlepsze wrażenia, korzystamy z technologii, takich jak pliki cookie, do przechowywania i/lub uzyskiwania dostępu do informacji o urządzeniu. Zgoda na te technologie pozwoli nam przetwarzać dane, takie jak zachowanie podczas przeglądania lub unikalne identyfikatory na tej stronie. Brak wyrażenia zgody lub wycofanie zgody może niekorzystnie wpłynąć na niektóre cechy i funkcje.
Funkcjonalne Zawsze aktywne
Przechowywanie lub dostęp do danych technicznych jest ściśle konieczny do uzasadnionego celu umożliwienia korzystania z konkretnej usługi wyraźnie żądanej przez subskrybenta lub użytkownika, lub wyłącznie w celu przeprowadzenia transmisji komunikatu przez sieć łączności elektronicznej.
Preferencje
Przechowywanie lub dostęp techniczny jest niezbędny do uzasadnionego celu przechowywania preferencji, o które nie prosi subskrybent lub użytkownik.
Statystyka
Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do celów statystycznych. Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do anonimowych celów statystycznych. Bez wezwania do sądu, dobrowolnego podporządkowania się dostawcy usług internetowych lub dodatkowych zapisów od strony trzeciej, informacje przechowywane lub pobierane wyłącznie w tym celu zwykle nie mogą być wykorzystywane do identyfikacji użytkownika.
Marketing
Przechowywanie lub dostęp techniczny jest wymagany do tworzenia profili użytkowników w celu wysyłania reklam lub śledzenia użytkownika na stronie internetowej lub na kilku stronach internetowych w podobnych celach marketingowych.
  • Zarządzaj opcjami
  • Zarządzaj serwisami
  • Zarządzaj {vendor_count} dostawcami
  • Przeczytaj więcej o tych celach
Zobacz preferencje
  • {title}
  • {title}
  • {title}