Meta
Cheguei bem longe trabalhando com IA para me ajudar a construir um Trie para encontrar palavras que rimam. Basicamente, digamos que você tem 10 milhões de palavras em inglês escritas usando o sistema de pronúncia CMU , onde cada fonema é um nó no Trie ( ARPABET
nesta imagem).
Aqui estão os principais recursos:
- Os nós Trie são símbolos ASCII 1+ . Como
B
para o som "b" eCH
para o som "ch", etc. - Trie pode paginar e pular de uma página específica. Então, ele pega uma propriedade
limit
, epage
. - A paginação pode ser opcionalmente limitada a correspondências de um comprimento de sequência de fonemas em particular . Como fonemas de
g l a d
seriam 4. - A entrada de pesquisa Trie (fornecida pelo usuário) é uma matriz de fonemas . Fonemas são as unidades de som em ASCII (como o
ARPABET
acima). - A entrada para o trie é expandida para todas as rimas possíveis . Ela é expandida usando um mapa de fonema para array de fonemas (implementado apenas parcialmente na função abaixo, levará semanas para ajustar adequadamente os valores aqui, o que preciso de TODO em breve).
- Podemos chamar a entrada expandida de "
rhymingPhonemeArray
" . - Cada sequência de fonemas no
rhymingPhonemeArray
é ponderada . Por "ponderada", esse peso é basicamente "quão próximo o fonema mapeado está do fonema original". Isso é para que possamos dizer "essa sequência de fonemas (com um peso cumulativo menor ) é uma rima melhor do que essa outra sequência de fonemas norhymingPhonemeArray
".
Problemas
O(s) problema(s) que estou enfrentando agora (com a solução que encontramos, compartilhada abaixo em JavaScript), são:
- Percorrendo todo o trie para encontrar todas as correspondências possíveis , o que não é otimizado. Idealmente, ele só percorre o que precisa (a página / quantidade limite e deslocamento).
- O Trie classifica todo o conjunto de correspondências depois , em vez de fazer o que queremos, que é obter a quantidade de páginas/limites já classificada. Este é o problema principal, não tenho certeza se/como ele pode fazer isso de forma otimizada, ou se é mesmo possível.
- O
rhymingPhonemeArray
é iterado por 😔 , então se estivermos paginando e orhymingPhonemeArray
for como[G-A-D (cum-weight: 10), G-L-A-D (cum-weight: 24 or whatever), G-R-A-D (cum-weight: 29), etc.]
, você vai encontrar tudo que rima comG-A-D
primeiro, então depois disso é paginado por, paginar porG-L-A-D
, etc.. Eu gostaria de evitar esse agrupamento . Em vez disso, o peso cumulativo precisa ser classificado e paginado por meio do "conjunto global" de todas as 10 milhões de palavras.
Portanto, para (3), ele deve encontrar ( algo como isto):
input: G-A-D
matches:
G-A-D
G-L-A-D
A-G-A-D
A-R-G-A-D
G-R-A-D
A-G-R-A-D
A-R-G-L-A-D
A-R-G-R-A-D
...
Por "algo assim", quero dizer, observe como NÃO é assim (onde primeiro, todas as G-A-D
correspondências são encontradas, depois todas G-L-A-D
são encontradas etc.):
input: G-A-D
matches:
G-A-D
A-G-A-D
A-R-G-A-D
G-L-A-D
A-R-G-L-A-D
G-R-A-D
A-G-R-A-D
A-R-G-R-A-D
...
Em vez disso, no primeiro matches
, ele é mais entrelaçado , o que deve ser baseado no peso cumulativo global de cada palavra.
Pergunta
Como você pode modificar a seguinte "Implementação Trie" para resolver os problemas de 1, 2 e 3 acima? Nota: não precisa ser 100% exato, apenas buscando o insight chave sobre como resolver esse problema de paginação corretamente (mesmo que apenas em um nível alto/pseudocódigo). Eles são todos aspectos do mesmo problema subjacente, que é o que eu já declarei, mas declararei novamente:
A implementação do Trie abaixo não pagina corretamente e de forma um tanto eficiente as palavras classificadas por peso cumulativo global , que são correspondências exatas para
rhymingPhonemeArray
(que é gerado a partir daphonemes
entrada).
É possível resolver esse problema sem ter que iterar sobre o Trie inteiro? Dado (lembre-se), a entrada é expandida para rhymingPhonemeArray
(o que pode ser um monte de possibilidades, mas vamos praticamente limitar a entrada a provavelmente 3 sílabas, além do ponto). Se não for possível, você pode explicar por que não é possível?
Se possível, como você modificaria esta tela para suportar paginação e pular para uma página específica sem precisar percorrer tudo, enquanto ao mesmo tempo os resultados paginados são classificados globalmente pelo peso cumulativo de cada palavra?
Implementação Trie
O Trie em que pousamos era este:
class TrieNode {
constructor() {
this.children = {}; // Store child nodes
this.isWord = false; // Flag to check if node marks the end of a word
this.word = null; // Store the word if this is an end node
this.cumulativeWeight = 0; // Store the cumulative weight for sorting
this.phonemeLength = 0; // Length of the phoneme sequence
}
}
class PhoneticTrie {
constructor() {
this.root = new TrieNode(); // Root node of the trie
}
// Insert words and phoneme clusters into the Trie
insert(word, phonemes, weight) {
let node = this.root;
for (let phoneme of phonemes) {
if (!node.children[phoneme]) {
node.children[phoneme] = new TrieNode();
}
node = node.children[phoneme];
}
node.isWord = true;
node.word = word;
node.cumulativeWeight = weight; // Store the cumulative weight at this node
node.phonemeLength = phonemes.length; // Store the length of the phoneme sequence
}
// Global sorting of rhyming phoneme matches using a min-heap
searchWithPagination({ phonemes, limit, page, length }) {
const minHeap = new MinHeap(); // Min-Heap to store sorted results
// Generate rhyming phoneme variations
const rhymingPhonemesArray = expandToRhymingPhonemes(phonemes);
// Search the Trie for all rhyming phoneme sequences and insert results into the global heap
for (let rhyme of rhymingPhonemesArray) {
this.searchAndInsertToHeap(rhyme.phonemes, this.root, rhyme.weight, minHeap, length);
}
// Paginate results directly from the globally sorted heap
const paginatedResults = [];
const startIndex = (page - 1) * limit;
let index = 0;
while (minHeap.size() > 0 && paginatedResults.length < limit) {
const wordData = minHeap.extractMin();
if (index >= startIndex) {
paginatedResults.push(wordData.word);
}
index++;
}
return paginatedResults;
}
// Search a specific phoneme sequence in the Trie and insert matches into the heap
searchAndInsertToHeap(phonemes, node, rhymeWeight, heap, targetLength, depth = 0, phonemeSeq = []) {
if (depth === phonemes.length) {
if (node.isWord && node.word) {
// If length filtering is specified, ensure the word matches the phoneme length
if (!targetLength || node.phonemeLength === targetLength) {
heap.insert({
word: node.word,
cumulativeWeight: node.cumulativeWeight + rhymeWeight, // Add the rhyme weight here
phonemeLength: node.phonemeLength, // Include phoneme length for sorting
});
}
}
return;
}
const phoneme = phonemes[depth];
if (!node.children[phoneme]) return; // No match
this.searchAndInsertToHeap(phonemes, node.children[phoneme], rhymeWeight, heap, targetLength, depth + 1, [...phonemeSeq, phoneme]);
}
}
class MinHeap {
constructor() {
this.heap = []; // Store the heap elements
}
// Insert an item into the heap based on its weight and phoneme length
insert({ word, cumulativeWeight, phonemeLength }) {
this.heap.push({ word, cumulativeWeight, phonemeLength });
this.bubbleUp(this.heap.length - 1);
}
bubbleUp(index) {
let currentIndex = index;
while (currentIndex > 0) {
const parentIndex = Math.floor((currentIndex - 1) / 2);
// Sort primarily by cumulative weight, secondarily by phoneme length
if (
this.heap[currentIndex].cumulativeWeight > this.heap[parentIndex].cumulativeWeight ||
(this.heap[currentIndex].cumulativeWeight === this.heap[parentIndex].cumulativeWeight &&
this.heap[currentIndex].phonemeLength > this.heap[parentIndex].phonemeLength)
) {
break;
}
[this.heap[currentIndex], this.heap[parentIndex]] = [this.heap[parentIndex], this.heap[currentIndex]];
currentIndex = parentIndex;
}
}
// Extract the item with the lowest weight
extractMin() {
if (this.heap.length === 0) return null;
if (this.heap.length === 1) return this.heap.pop();
const min = this.heap[0];
this.heap[0] = this.heap.pop();
this.bubbleDown(0);
return min;
}
bubbleDown(index) {
const lastIndex = this.heap.length - 1;
while (true) {
let leftChildIdx = 2 * index + 1;
let rightChildIdx = 2 * index + 2;
let smallestIdx = index;
if (
leftChildIdx <= lastIndex &&
(this.heap[leftChildIdx].cumulativeWeight < this.heap[smallestIdx].cumulativeWeight ||
(this.heap[leftChildIdx].cumulativeWeight === this.heap[smallestIdx].cumulativeWeight &&
this.heap[leftChildIdx].phonemeLength < this.heap[smallestIdx].phonemeLength))
) {
smallestIdx = leftChildIdx;
}
if (
rightChildIdx <= lastIndex &&
(this.heap[rightChildIdx].cumulativeWeight < this.heap[smallestIdx].cumulativeWeight ||
(this.heap[rightChildIdx].cumulativeWeight === this.heap[smallestIdx].cumulativeWeight &&
this.heap[rightChildIdx].phonemeLength < this.heap[smallestIdx].phonemeLength))
) {
smallestIdx = rightChildIdx;
}
if (smallestIdx === index) break;
[this.heap[index], this.heap[smallestIdx]] = [this.heap[smallestIdx], this.heap[index]];
index = smallestIdx;
}
}
size() {
return this.heap.length;
}
}
function expandToRhymingPhonemes(phonemes) {
// todo: this function would have a huge map of phoneme substitutions...
const phonemeSubstitutions = {
"k": ["g", "p"], // Example of substitutions for the phoneme "k"
"æ": ["a", "e"], // Example for "æ"
"t": ["d", "s"], // Example for "t"
};
const rhymingPhonemesArray = [];
function generateRhymes(sequence, depth = 0, currentWeight = 0) {
if (depth === sequence.length) {
rhymingPhonemesArray.push({ phonemes: sequence.slice(), weight: currentWeight });
return;
}
const phoneme = sequence[depth];
const substitutions = phonemeSubstitutions[phoneme] || [phoneme];
for (const sub of substitutions) {
generateRhymes(
[...sequence.slice(0, depth), sub, ...sequence.slice(depth + 1)],
depth + 1,
currentWeight + (sub === phoneme ? 0 : 1) // Substitution adds to weight
);
}
}
generateRhymes(phonemes);
return rhymingPhonemesArray;
}
const trie = new PhoneticTrie();
trie.insert("glad", ["g", "l", "a", "d"], 3);
trie.insert("grad", ["g", "r", "a", "d"], 2);
trie.insert("blad", ["b", "l", "a", "d"], 4);
trie.insert("grin", ["g", "r", "i", "n"], 5);
// Search for similar words to "g-l-a-d" and paginate the results, with optional length filtering
const resultsPage1 = trie.searchWithPagination({
phonemes: ["g", "l", "a", "d"],
limit: 2,
page: 1,
length: 4, // Only consider words with exactly 4 phonemes
});
const resultsPage2 = trie.searchWithPagination({
phonemes: ["g", "l", "a", "d"],
limit: 2,
page: 2,
length: 4,
});
console.log(resultsPage1); // Output: ["grad", "glad"] (sorted by weight and length)
console.log(resultsPage2); // Output: ["blad", "grin"]
Atualizações
- Aqui está um resumo com mais expansões/tentativas do Trie para resolver esse problema fundamental.
O problema fundamental que você tem é que o trie implica uma ordenação lexical global, mas você quer sua saída em termos de uma ordenação de peso global.
O trie não está ajudando você.
Uma estrutura de dados apropriada para esse problema consistiria em uma matriz de palavras ordenadas por peso em combinação com um índice invertido que fornece, para cada sufixo fonético com o qual você deseja rimar, a lista ordenada de índices de palavras que rimam.
Você provavelmente pode usar uma implementação de índice invertido de código aberto.