Graph

មាតិកា

  1. របស់ដែលភ្ជាប់ជាមួយរបស់
  2. វាក្យសព្ទ Graph
  3. Adjacency List ឬ Matrix
  4. សាងសង់ Class Graph
  5. Breadth-First Search
  6. Depth-First Search
  7. ផ្លូវខ្លីបំផុត នៅពេល Edge ទាំងអស់មានតម្លៃស្មើគ្នា
  8. Connected Component
  9. Cycle និង Topological Sort
  10. Dijkstra៖ ផ្លូវខ្លីបំផុតដែលមាន Weight
  11. បញ្ហាសម្ភាសន៍លើ Grid និង Prerequisite
  12. រូបមន្ត៖ graph.ts សម្រាប់ algolab
  13. សាកល្បងដោយខ្លួនឯង

1. របស់ដែលភ្ជាប់ជាមួយរបស់

Tree គឺជារចនាសម្ព័ន្ធដ៏តឹងរ៉ឹងមួយ៖ មាន root តែមួយ និងមានផ្លូវតែមួយទៅកាន់រាល់ node។ ជីវិតពិតរញ៉េរញ៉ៃជាងនេះ។ មិត្តភក្តិស្គាល់មិត្តភក្តិដែលស្គាល់គ្នាទៅវិញទៅមក។ បណ្តាញផ្លូវមានរង្វង់។ ផែនទីទូរស័ព្ទមាន tower ដែលភ្ជាប់គ្នាក្នុងទិសជាច្រើន។ package មួយពឹងលើ package ផ្សេងទៀត ដែលពឹងលើ package ផ្សេងទៀតទៀត។

នៅពេល "របស់" ភ្ជាប់ជាមួយ "របស់" ផ្សេងទៀតតាមលំនាំណាមួយ អ្នកមាន graph។ Graph គឺជា data structure ទូទៅបំផុតក្នុងស៊េរីនេះ។ Tree, linked list និងសូម្បីតែ array ក៏អាចមើលជាករណីពិសេសនៃ graph បានដែរ។

កន្លែងដែលអ្នកជួប graph នៅកន្លែងធ្វើការ៖

  • ផែនទី និងផ្លូវ។ ទីក្រុងជាចំណុច ផ្លូវជាបន្ទាត់។ "ផ្លូវលឿនបំផុតពីកែបទៅបាត់ដំបង" គឺជាសំណួរ graph។
  • បណ្តាញសង្គម។ មនុស្សជាចំណុច មិត្តភាពជាបន្ទាត់។ "មនុស្សដែលអ្នកប្រហែលស្គាល់" មានន័យថាមិត្តភក្តិរបស់មិត្តភក្តិ។
  • Dependency។ npm install អាន graph នៃ package ដែលត្រូវការ package ផ្សេងទៀត។ Build tool, spreadsheet (cell មួយពឹងលើ cell ផ្សេងទៀត) និង database migration ទាំងអស់ត្រូវដឹងថាអ្វីត្រូវដំណើរការមុន។
  • បណ្តាញ។ Router, server និង microservice ដែលហៅគ្នាទៅវិញទៅមក។
  • State machine និង workflow។ ការបញ្ជាទិញមួយផ្លាស់ពី "placed" ទៅ "paid" ទៅ "shipped"។ រាល់ state ជាចំណុច ហើយរាល់ការផ្លាស់ប្តូរដែលអនុញ្ញាតជាបន្ទាត់។
  • ការណែនាំ (Recommendation)។ "អ្នកប្រើដែលទិញរបស់នេះក៏ទិញ..." គឺជា graph រវាងអ្នកប្រើ និងផលិតផល។

នៅក្នុងជំពូកនេះ អ្នកនឹងរៀនវាក្យសព្ទ វិធីពីរក្នុងការរក្សាទុក graph ការស្វែងរកស្នូលពីរ (BFS និង DFS) និងបញ្ហាបីដែលលេចឡើងម្តងហើយម្តងទៀត៖ ផ្លូវខ្លីបំផុត "តើគួរធ្វើទាំងនេះតាមលំដាប់ណា?" និង "តើផ្លូវណាថោកបំផុត?"។

2. វាក្យសព្ទ Graph

Graph មានធាតុផ្សំពីរ៖

  • Vertex (ហៅម្យ៉ាងទៀតថា node)៖ របស់ទាំងឡាយ។ ឯកវចនៈគឺ vertex។
  • Edge៖ ការតភ្ជាប់រវាង vertex ពីរ។
   Kratie --- Kampong Cham --- Phnom Penh --- Siem Reap
                                  |               |
                                Kampot        Battambang
                                  |
                                 Kep

ពាក្យមួយចំនួនដែលត្រូវដឹង៖

ពាក្យអត្ថន័យ
UndirectedEdge ដំណើរការទាំងពីរទិស។ ប្រសិនបើ A ជាមិត្តរបស់ B នោះ B ក៏ជាមិត្តរបស់ A។
DirectedEdge មានទិសដៅ គូរជាព្រួញ។ "A តាមដាន B" មិនមានន័យថា B តាមដាន A ទេ។
WeightedEdge នីមួយៗកាន់លេខមួយ៖ ចម្ងាយ ថ្លៃ ឬពេលវេលា។
NeighborVertex ដែលភ្ជាប់ដោយផ្ទាល់ទៅ vertex មួយ។
Pathលំដាប់នៃ vertex ដែលគូជាប់គ្នានីមួយៗត្រូវបានភ្ជាប់ដោយ edge។
CyclePath ដែលត្រឡប់មកកន្លែងដែលវាចាប់ផ្តើមវិញ។
Connectedអ្នកអាចទៅពី vertex ណាមួយទៅ vertex ផ្សេងទៀតបានតាម path ណាមួយ។
Degreeចំនួន edge ដែលប៉ះ vertex មួយ (directed graph បំបែកវាជា in-degree និង out-degree)។
DAGDirected acyclic graph៖ មានតែព្រួញ ហើយគ្មាន cycle។ Dependency របស់ task គឺជា DAG។

សូមកត់សម្គាល់ទំនាក់ទំនងជាមួយជំពូកមុន។ Tree គឺជា graph ដែល connected ហើយគ្មាន cycle (ហើយនៅពេលគូរជាមួយ root នោះមានទិសដៅចង្អុលចុះក្រោម)។ នៅពេលអ្នកអនុញ្ញាតឲ្យមាន cycle អ្នកក៏ត្រូវចងចាំ vertex ណាដែលអ្នកបានទស្សនារួចហើយ បើមិនដូច្នោះទេ ការស្វែងរករបស់អ្នកនឹងវិលជុំគ្មានទីបញ្ចប់។ នេះគឺជាភាពខុសគ្នាធំតែមួយរវាងការដើរលើ tree និងការដើរលើ graph ហើយវាលេចឡើងក្នុងរាល់ algorithm ខាងក្រោមជា seen set។

3. Adjacency List ឬ Matrix

តើអ្នករក្សាទុក graph ក្នុងអង្គចងចាំយ៉ាងដូចម្តេច? មានចម្លើយបុរាណពីរ។

Adjacency matrix។ ក្រឡាចត្រង្គមួយដែលមានជួរដេកមួយ និងជួរឈរមួយសម្រាប់ vertex នីមួយៗ។ ក្រឡានៅជួរដេក i ជួរឈរ j គឺ 1 (ឬ weight) ប្រសិនបើមាន edge ពី i ទៅ j បើមិនដូច្នោះទេ 0។ សម្រាប់ vertex បី A, B, C ដែល A-B និង B-C ភ្ជាប់គ្នា៖

const names = ["A", "B", "C"];
const matrix = names.map(() => names.map(() => 0));

matrix[0][1] = matrix[1][0] = 1; // A - B
matrix[1][2] = matrix[2][1] = 1; // B - C

console.log(matrix);
// [ [ 0, 1, 0 ], [ 1, 0, 1 ], [ 0, 1, 0 ] ]

Adjacency list។ សម្រាប់ vertex នីមួយៗ រក្សាបញ្ជី neighbor របស់វា។ ក្នុង TypeScript Map ពី vertex ទៅ neighbor របស់វាគឺល្អឥតខ្ចោះ៖

const list = new Map<string, string[]>([
  ["A", ["B"]],
  ["B", ["A", "C"]],
  ["C", ["B"]],
]);

ត្រូវប្រើមួយណា? វាអាស្រ័យលើចំនួន edge ធៀបនឹង vertex (V vertex, E edge)៖

Adjacency listAdjacency matrix
អង្គចងចាំO(V + E)O(V²)
តើមាន edge ពី A ទៅ B ទេ?O(degree នៃ A) ឬ O(1) ជាមួយ Map នៃ MapO(1)
រាយ neighbor ទាំងអស់របស់ AO(degree នៃ A)O(V)
បន្ថែម vertexO(1)O(V²) ដើម្បីសាងសង់ឡើងវិញ
ល្អបំផុតសម្រាប់Sparse graph (edge តិច)Dense graph (ស្ទើរតែគ្រប់គូភ្ជាប់គ្នា)

Graph ពិតភាគច្រើនជា sparse។ បណ្តាញសង្គមដែលមានមនុស្សមួយពាន់លាននាក់ មិនមែនមានមិត្តភក្តិមួយពាន់លាននាក់ម្នាក់ៗទេ។ Matrix សម្រាប់ vertex មួយលានត្រូវការក្រឡាមួយទ្រីលាន ដែលភាគច្រើនជាសូន្យ ខណៈ list រក្សាទុកតែ edge ដែលមានពិតប៉ុណ្ណោះ។ នោះហើយជាមូលហេតុដែល adjacency list ជាជម្រើសលំនាំដើម ហើយជាមូលហេតុដែលយើងសាងសង់វានៅផ្នែកបន្ទាប់។ ប្រើ matrix នៅពេល graph តូច និងក្រាស់ ឬនៅពេលអ្នកត្រូវសាកល្បង "តើ A ភ្ជាប់ទៅ B ឬទេ?" ញឹកញាប់ខ្លាំង។

4. សាងសង់ Class Graph

យើងរក្សាទុក graph ជា Map ពី vertex នីមួយៗទៅ Map ទីពីរដែលកាន់ neighbor របស់វា និង weight នៃ edge នីមួយៗ។ ការប្រើ Map សម្រាប់ neighbor ផ្តល់ការពិនិត្យ "តើមាន edge ទេ?" ក្នុង O(1) ហើយ slot weight អនុញ្ញាតឲ្យ class តែមួយដោះស្រាយទាំង weighted និង unweighted graph (edge ដែលគ្មាន weight គ្រាន់តែទទួល weight 1)។

class Graph<T = string> {
  private adjacency = new Map<T, Map<T, number>>();

  constructor(private directed = false) {}

  addVertex(vertex: T): void {
    if (!this.adjacency.has(vertex)) this.adjacency.set(vertex, new Map());
  }

  addEdge(from: T, to: T, weight = 1): void {
    this.addVertex(from);
    this.addVertex(to);
    this.adjacency.get(from)!.set(to, weight);
    if (!this.directed) this.adjacency.get(to)!.set(from, weight);
  }

  hasEdge(from: T, to: T): boolean {
    return this.adjacency.get(from)?.has(to) ?? false;
  }

  vertices(): T[] {
    return [...this.adjacency.keys()];
  }

  neighbors(vertex: T): T[] {
    return [...(this.adjacency.get(vertex)?.keys() ?? [])];
  }
}

ព័ត៌មានលម្អិតមួយចំនួនដែលគួរអានយឺតៗ៖

  • Graph<T = string> គឺជា generic ដែលមានតម្លៃលំនាំដើម។ សរសេរ new Graph() នោះ vertex ជា string។ សរសេរ new Graph<number>() នោះវាជាលេខ។ អ្វីៗដែលអាចធ្វើជា key របស់ Map បាន អាចជា vertex បាន។
  • new Graph(true) បង្កើត directed graph។ លំនាំដើមគឺ undirected ដែល addEdge កត់ត្រាការតភ្ជាប់ក្នុង ទាំងពីរ ទិស។
  • addEdge បង្កើត vertex តាមតម្រូវការ ដូច្នេះអ្នកមិនចាំបាច់ហៅ addVertex មុនទេ (ប៉ុន្តែអ្នកអាចហៅ ដើម្បីបន្ថែម vertex ដែលមិនទាន់មាន edge)។
  • សញ្ញា ! បន្ទាប់ពី .get(from) មានសុវត្ថិភាព ព្រោះយើងទើបតែបន្ថែម vertex នៅបន្ទាត់ខាងលើ។

តោះសាងសង់ផែនទីផ្លូវតូចមួយនៃប្រទេសកម្ពុជា (ការតភ្ជាប់ប្រឌិតសម្រាប់ហាត់)៖

const roads = new Graph();
roads.addEdge("Phnom Penh", "Kampong Cham");
roads.addEdge("Phnom Penh", "Kampot");
roads.addEdge("Phnom Penh", "Siem Reap");
roads.addEdge("Kampong Cham", "Kratie");
roads.addEdge("Siem Reap", "Battambang");
roads.addEdge("Kampot", "Kep");

console.log(roads.neighbors("Phnom Penh"));
// [ 'Kampong Cham', 'Kampot', 'Siem Reap' ]
console.log(roads.hasEdge("Kep", "Kampot")); // true

ថ្លៃនៃការរក្សាទុក graph គឺអង្គចងចាំ O(V + E) ហើយ addEdge គឺ O(1)។ ឥឡូវទៅផ្នែកគួរឲ្យចាប់អារម្មណ៍៖ ការស្វែងរកវា។

Breadth-first search (BFS) រុករក graph ជារង្វង់ៗ។ ដំបូង vertex ចាប់ផ្តើម។ បន្ទាប់មក neighbor ទាំងអស់របស់វា (ឆ្ងាយមួយជំហាន)។ បន្ទាប់មក neighbor ទាំងអស់របស់ ពួកវា (ឆ្ងាយពីរជំហាន) ហើយបន្តដូច្នេះទៅ។ វាជាគំនិតកម្រិតមួយៗដូចដែលអ្នកបានឃើញសម្រាប់ tree ជាមួយការបន្ថែមមួយ៖ seen set ដើម្បីកុំឲ្យ vertex ណាត្រូវបានបន្ថែមពីរដង។

BFS ត្រូវការ queue (first in, first out) ដែលជាអ្វីដែលធ្វើឲ្យវាដើររង្វង់ម្តងមួយៗ។ យើងនឹងប្រើ array ធម្មតាជាមួយ index head ជំនួសឲ្យ shift()។ ដូចដែលអ្នកបានរៀនក្នុងជំពូក Array និង String shift() អាចជា O(n) ខណៈពេលដែលការរុញ index ទៅមុខគឺ O(1)។ (អ្នកក៏អាចប្រើ Queue ដែលអ្នកបានសាងសង់ក្នុង Stack និង Queue ដែរ។)

bfs(start: T): T[] {
  if (!this.adjacency.has(start)) return [];

  const order: T[] = [];
  const seen = new Set<T>([start]);
  const queue: T[] = [start];

  for (let head = 0; head < queue.length; head++) {
    const vertex = queue[head];
    order.push(vertex);

    for (const next of this.neighbors(vertex)) {
      if (!seen.has(next)) {
        seen.add(next); // mark when you ADD it, not when you visit it
        queue.push(next);
      }
    }
  }
  return order;
}

សាកល្បងវាលើផែនទីផ្លូវ៖

console.log(roads.bfs("Phnom Penh").join(", "));
// Phnom Penh, Kampong Cham, Kampot, Siem Reap, Kratie, Kep, Battambang

សូមមើលលំនាំរង្វង់។ ភ្នំពេញគឺជំហានទី 0។ Neighbor ទាំងបីរបស់វាគឺជំហានទី 1។ Kratie, Kep និង Battambang គឺជំហានទី 2 ហើយពួកវាមកចុងក្រោយ។

ព័ត៌មានលម្អិតមួយមានសារៈសំខាន់ខ្លាំង៖ យើងសម្គាល់ vertex ថា seen នៅពេលដែលយើង ដាក់វាចូល queue មិនមែននៅពេលយើងយកវាចេញទេ។ ប្រសិនបើយើងរង់ចាំរហូតដល់យកវាចេញ vertex ពីរអាចដាក់ neighbor តែមួយចូល queue មុនពេលណាមួយត្រូវបានដំណើរការ ហើយ neighbor នោះនឹងត្រូវដាក់ចូល queue ពីរដង។ នៅលើ graph ធំ នោះអាចបំប្លែង algorithm លឿនឲ្យទៅជាយឺតខ្លាំង។

ថ្លៃ៖ vertex នីមួយៗត្រូវបានដាក់ចូល queue ម្តង ហើយ edge នីមួយៗត្រូវបានមើលម្តង (ពីរដងក្នុង undirected graph) ដូច្នេះ BFS មានពេលវេលា O(V + E) និងអង្គចងចាំ O(V)។

Depth-first search (DFS) ធ្វើផ្ទុយគ្នា៖ ទៅឲ្យជ្រៅបំផុតតាមផ្លូវមួយ ហើយនៅពេលជាប់ទើបថយក្រោយ ហើយសាកល្បងផ្លូវផ្សេង។ វាជាកំណែ graph នៃ tree DFS ពីជំពូកមុន ជាមួយ seen set ដូចគ្នាដើម្បីជៀសវាង loop។ កំណែ recursive គឺខ្លី៖

dfs(start: T): T[] {
  const order: T[] = [];
  const seen = new Set<T>();

  const visit = (vertex: T): void => {
    seen.add(vertex);
    order.push(vertex);
    for (const next of this.neighbors(vertex)) {
      if (!seen.has(next)) visit(next);
    }
  };

  if (this.adjacency.has(start)) visit(start);
  return order;
}
console.log(roads.dfs("Phnom Penh").join(", "));
// Phnom Penh, Kampong Cham, Kratie, Kampot, Kep, Siem Reap, Battambang

ប្រៀបធៀប output ទាំងពីរ។ BFS លោតឆ្លងកាត់៖ neighbor ទាំងបីរបស់ភ្នំពេញមុនអ្វីផ្សេងទៀត។ DFS ជ្រមុជចុះ៖ ភ្នំពេញ បន្ទាប់មកកំពង់ចាម បន្ទាប់មកទៅត្រង់ Kratie (ផ្លូវទាល់) បន្ទាប់មកថយក្រោយដើម្បីសាកល្បងកំពតហើយបន្តទៅកែប ហើយបន្តដូច្នេះទៅ។ វាប្រើ call stack ជាអង្គចងចាំរបស់វា ដូចគ្នានឹង tree DFS ដូច្នេះច្រវាក់វែងខ្លាំង (vertex មួយលានតម្រៀបជាបន្ទាត់) អាចបណ្តាលឲ្យ stack overflow។ អ្នកអាចជំនួស recursion ដោយ array stack ច្បាស់លាស់នៅពេលមានហានិភ័យនោះ។

ការស្វែងរកទាំងពីរមានថ្លៃ O(V + E)។ ដូច្នេះនៅពេលណាអ្នករើសមួយណា?

សំណួរប្រើ
ជំហានតិចបំផុតពី A ទៅ BBFS
អ្វីៗទាំងអស់ក្នុងចម្ងាយ N ជំហានពី A (មិត្តភក្តិរបស់មិត្តភក្តិ)BFS
តើ B អាចទៅដល់ពី A ដែរឬទេ?មួយណាក៏បាន
រុករកគ្រប់លទ្ធភាព ឧ. ដោះស្រាយ maze ឬល្បែងផ្គុំរូប ហើយថយក្រោយDFS
រកឃើញ cycle តម្រៀប task រក componentDFS (ឬវិធីផ្អែកលើ BFS)
Graph ជ្រៅ និងស្តើងខ្លាំងBFS (ជៀសវាង recursion ជ្រៅ)
Graph ទូលាយខ្លាំងDFS (queue របស់ BFS អាចកាន់រង្វង់ធំៗ)

ប្រសិនបើអ្នកចងចាំច្បាប់តែមួយ៖ "ផ្លូវខ្លីបំផុតតាមចំនួន edge" មានន័យថា BFS។

7. ផ្លូវខ្លីបំផុត នៅពេល Edge ទាំងអស់មានតម្លៃស្មើគ្នា

ដោយសារ BFS ទស្សនា vertex តាមលំដាប់ចម្ងាយ លើកដំបូង ដែលវាទៅដល់ vertex មួយ គឺវាបានរកឃើញផ្លូវខ្លីបំផុតទៅកាន់វារួចហើយ។ ដើម្បីទទួលបាន path ពិតប្រាកដ (មិនមែនគ្រាន់តែលំដាប់) ត្រូវចងចាំ អ្នកណាបានរកឃើញអ្នកណា។ បន្ទាប់មកដើរថយក្រោយពី target ទៅ start។

shortestPath(from: T, to: T): T[] | null {
  if (!this.adjacency.has(from) || !this.adjacency.has(to)) return null;

  const cameFrom = new Map<T, T | null>([[from, null]]);
  const queue: T[] = [from];

  for (let head = 0; head < queue.length; head++) {
    const vertex = queue[head];
    if (vertex === to) break; // found it: no need to look further

    for (const next of this.neighbors(vertex)) {
      if (!cameFrom.has(next)) {
        cameFrom.set(next, vertex);
        queue.push(next);
      }
    }
  }

  if (!cameFrom.has(to)) return null; // never reached

  const path: T[] = [];
  for (
    let v: T | null | undefined = to;
    v !== null && v !== undefined;
    v = cameFrom.get(v)
  ) {
    path.push(v);
  }
  return path.reverse();
}

cameFrom map ធ្វើការពីរយ៉ាង៖ វាជា seen set (vertex មួយត្រូវបានឃើញ ប្រសិនបើវាមាន entry) និងជាផ្លូវដាននៃដំណើរ។ Start មាន null ដែលបញ្ឈប់ការដើរថយក្រោយ។

console.log(roads.shortestPath("Kep", "Battambang")?.join(" -> "));
// Kep -> Kampot -> Phnom Penh -> Siem Reap -> Battambang

console.log(roads.shortestPath("Kep", "Nowhere"));
// null

បួន edge គឺជាចំនួន hop តិចបំផុតដែលអាចធ្វើបានពីកែបទៅបាត់ដំបងលើផែនទីនេះ។ កម្មវិធីផែនទីពិតប្រើច្រើនជាងចំនួន hop ព្រោះដំណើរ 4 hop លើផ្លូវអាក្រក់ ឈ្នះគ្មានអ្វីសោះ ប៉ុន្តែចាញ់ដំណើរ 5 hop លើផ្លូវហាយវេ។ នោះត្រូវការ weight ដែលនឹងត្រូវគ្របដណ្តប់ក្នុងផ្នែកទី 10។

នេះក៏ឆ្លើយគំនិតបុរាណ "six degrees of separation" ដែរ៖ BFS ពីមនុស្សម្នាក់រកឃើញច្រវាក់មិត្តភក្តិខ្លីបំផុតទៅកាន់អ្នកណាផ្សេងទៀត។

8. Connected Component

ក្នុង undirected graph connected component គឺជាក្រុម vertex ដែលអាចទៅដល់គ្នាទៅវិញទៅមកបានទាំងអស់ ដោយគ្មានការតភ្ជាប់ទៅផ្នែកដែលនៅសល់។ គិតដល់ក្រុមមិត្តភក្តិផ្សេងគ្នានៅសាលាដែលមិនដែលនិយាយគ្នា ឬកោះ។

ដើម្បីរកវា៖ យក vertex ណាមួយ BFS ពីវា ហើយអ្វីៗដែលវាទៅដល់ជា component មួយ។ បន្ទាប់មកយក vertex ណាមួយ ដែលមិនទាន់ seen ហើយធ្វើម្តងទៀត។

connectedComponents(): T[][] {
  const seen = new Set<T>();
  const groups: T[][] = [];

  for (const vertex of this.vertices()) {
    if (seen.has(vertex)) continue;
    const group = this.bfs(vertex);
    for (const v of group) seen.add(v);
    groups.push(group);
  }
  return groups;
}
const g = new Graph();
g.addEdge("a", "b");
g.addEdge("c", "d");
g.addEdge("d", "e");
g.addVertex("f"); // alone

console.log(g.connectedComponents());
// [ [ 'a', 'b' ], [ 'c', 'd', 'e' ], [ 'f' ] ]

Vertex នីមួយៗត្រូវបានទស្សនាម្តងប៉ុណ្ណោះសរុប ដូច្នេះថ្លៃសរុបគឺ O(V + E)។ ការប្រើប្រាស់រួមមានការដាក់ក្រុម account ស្ទួន ការរកចង្កោមនៃឯកសារដែលទាក់ទងគ្នា និងការពិនិត្យថាតើបណ្តាញត្រូវបានបំបែកជាបំណែកឬទេ។ នៅជំពូកបន្ទាប់ អ្នកនឹងឃើញឧបករណ៍លឿនជាងសម្រាប់សំណួរ "តើទាំងពីរនេះភ្ជាប់គ្នាទេ?" ដែលសួរម្តងហើយម្តងទៀត ដែលហៅថា union-find។

9. Cycle និង Topological Sort

ឥឡូវ directed graph។ ឧបមាថាអ្នកមាន build pipeline មួយ៖

install --> build --> test --> deploy
lint -----> build
build --------------------> deploy

ព្រួញ A -> B មានន័យថា "A ត្រូវកើតឡើងមុន B"។ តើអ្នកអាចដំណើរការជំហានតាមលំដាប់អ្វីបាន? លំដាប់ណាដែលរាល់ព្រួញចង្អុល ទៅមុខ សុទ្ធតែប្រើបាន។ លំដាប់បែបនេះគឺជា topological sort ហើយវាមានពិតប្រាកដនៅពេល graph គ្មាន cycle។ (ប្រសិនបើ deploy ត្រូវមកមុន install អ្នកនឹងជាប់រហូត។ គ្មានអ្វីអាចទៅមុនបានទេ។)

ការរកឃើញ cycle។ ក្នុង directed graph cycle ត្រូវបានរកឃើញដោយ DFS និងបី state ក្នុងមួយ vertex៖ មិនទាន់ទស្សនា visiting (កំពុងនៅលើ path ដែលយើងកំពុងរុករក) និង done។ ប្រសិនបើយើងទៅដល់ vertex ដែលនៅតែ "visiting" នោះយើងបានដើរជារង្វង់៖

hasCycle(): boolean {
  const state = new Map<T, "visiting" | "done">();

  const visit = (vertex: T): boolean => {
    state.set(vertex, "visiting");
    for (const next of this.neighbors(vertex)) {
      const s = state.get(next);
      if (s === "visiting") return true; // back to a vertex on our path
      if (s === undefined && visit(next)) return true;
    }
    state.set(vertex, "done");
    return false;
  };

  for (const vertex of this.vertices()) {
    if (!state.has(vertex) && visit(vertex)) return true;
  }
  return false;
}

ហេតុអ្វីមិនប្រើ seen set ធម្មតា? ព្រោះក្នុង directed graph ការទៅដល់ vertex ដែល seen រួចហើយមិនមែនតែងតែជា cycle ទេ។ ប្រសិនបើ path ពីរនាំទៅ vertex តែមួយ (A ទៅ C និង B ទៅ C) នោះ C ជា "seen" នៅលើកទីពីរ ប៉ុន្តែគ្មាន loop ទេ។ មានតែការទៅដល់ vertex ដែល នៅតែនៅលើ path បច្ចុប្បន្ន ប៉ុណ្ណោះដែលបញ្ជាក់ពី cycle។ (សម្រាប់ undirected graph ច្បាប់ខុសគ្នា៖ cycle គឺជា edge ទៅ vertex ដែល seen ដែលមិនមែនជាអ្នកដែលអ្នកទើបតែមកពី។)

Topological sort (Kahn's algorithm)។ មានវិធីដ៏គួរឲ្យពេញចិត្តក្នុងការបង្កើតលំដាប់ដោយប្រើ BFS៖

  1. រាប់ edge ចូល របស់ vertex នីមួយៗ (មានអ្វីប៉ុន្មានត្រូវកើតឡើងមុនវា)។
  2. អ្វីៗដែលមាន edge ចូល 0 គឺរួចរាល់ឥឡូវនេះ។ ដាក់ពួកវាក្នុង queue។
  3. យកមួយចេញពី queue ហើយ output វា។ "បញ្ចប់" វាដោយលុប edge ចេញរបស់វា ដូច្នេះចំនួនរបស់ neighbor នីមួយៗថយចុះមួយ។ ប្រសិនបើចំនួនរបស់ neighbor ដល់ 0 វារួចរាល់ហើយ ដូច្នេះដាក់វាចូល queue។
  4. ប្រសិនបើអ្នក output គ្រប់ vertex អ្នករួចរាល់។ ប្រសិនបើនៅសល់មួយចំនួន ពួកវាជាប់ក្នុង cycle។
topologicalSort(): T[] | null {
  const incoming = new Map<T, number>();
  for (const v of this.vertices()) incoming.set(v, 0);
  for (const v of this.vertices()) {
    for (const next of this.neighbors(v)) {
      incoming.set(next, incoming.get(next)! + 1);
    }
  }

  const ready: T[] = this.vertices().filter((v) => incoming.get(v) === 0);
  const order: T[] = [];

  for (let head = 0; head < ready.length; head++) {
    const vertex = ready[head];
    order.push(vertex);
    for (const next of this.neighbors(vertex)) {
      const left = incoming.get(next)! - 1;
      incoming.set(next, left);
      if (left === 0) ready.push(next);
    }
  }

  return order.length === this.adjacency.size ? order : null;
}

សាកល្បងលើ pipeline៖

const tasks = new Graph(true);
tasks.addEdge("install", "build");
tasks.addEdge("lint", "build");
tasks.addEdge("build", "test");
tasks.addEdge("build", "deploy");
tasks.addEdge("test", "deploy");

console.log(tasks.topologicalSort());
// [ 'install', 'lint', 'build', 'test', 'deploy' ]
console.log(tasks.hasCycle()); // false

tasks.addEdge("deploy", "install"); // now deploy must precede install
console.log(tasks.topologicalSort()); // null
console.log(tasks.hasCycle());        // true

តាមដានការហៅលើកដំបូង។ install និង lint មាន edge ចូល 0 ដូច្នេះពួកវាទៅមុន។ ការបញ្ចប់ទាំងពីរបន្ថយ build ពី 2 edge ចូល ទៅ 0។ បន្ទាប់មក build ត្រូវបាន output ដែលធ្វើឲ្យ test រួចរាល់ (0) ហើយបន្ថយ deploy ពី 2 ទៅ 1។ test ត្រូវបាន output បន្ទាប់ ហើយ deploy ដល់ 0 ហើយមកចុងក្រោយ។

លំដាប់ត្រឹមត្រូវច្រើនអាចមាន (lint មុន install ក៏ប្រើបានដែរ)។ Topological sort ត្រឡប់ មួយ ក្នុងចំណោមពួកវា។ នេះគឺជាអ្វីដែល package manager ធ្វើដើម្បីសម្រេចលំដាប់ install អ្វីដែល build system ដូចជា Make និង Bazel ធ្វើ និងរបៀបដែល spreadsheet សម្រេចថា cell ណាត្រូវគណនាឡើងវិញមុន។ ទាំង hasCycle និង topologicalSort មានថ្លៃ O(V + E)។

10. Dijkstra៖ ផ្លូវខ្លីបំផុតដែលមាន Weight

BFS រកឃើញ path ដែលមាន edge តិចបំផុត។ ប៉ុន្តែនៅពេល edge មានថ្លៃ (គីឡូម៉ែត្រ នាទី ដុល្លារ) edge តិចបំផុតមិនមែនថោកបំផុតទេ។

        4
   A -------- B
    \        / \
   1 \    2 /   \ 1
      \    /     \
        C -------- D
             5

ផ្លូវជាមួយប្រវែងរបស់វាជាគីឡូម៉ែត្រ៖

const km = new Graph();
km.addEdge("A", "B", 4);
km.addEdge("A", "C", 1);
km.addEdge("C", "B", 2);
km.addEdge("B", "D", 1);
km.addEdge("C", "D", 5);

ដើម្បីទៅពី A ទៅ B ផ្លូវផ្ទាល់មានតម្លៃ 4។ ប៉ុន្តែ A -> C -> B មានតម្លៃ 1 + 2 = 3 ដែលថោកជាង ទោះបីជាវាប្រើពីរ hop។ BFS នឹងរើសផ្លូវផ្ទាល់។ យើងត្រូវការ algorithm របស់ Dijkstra (ប្រកាសថា "DYKE-struh") ដែលបង្កើតឡើងក្នុងឆ្នាំ 1956 ដោយ Edsger Dijkstra។

គំនិតគឺ៖ តែងតែ settle vertex ដែលថោកបំផុតដែលមិនទាន់ settle នៅបន្ទាប់។ រក្សា priority queue នៃគូ [vertex, costSoFar]។ Pop ថោកបំផុត។ ប្រសិនបើយើងបាន settle vertex នោះរួចហើយជាមួយថ្លៃទាបជាង ត្រូវរំលង។ បើមិនដូច្នោះ កត់ត្រាថ្លៃរបស់វាជាចុងក្រោយ ហើយ push neighbor នីមួយៗជាមួយថ្លៃនៃការទៅដល់ទីនោះតាមរយៈ vertex នេះ។ នេះគឺជាកន្លែងដែល PriorityQueue ពីជំពូកមុនបង្ហាញតម្លៃរបស់វា។

import { PriorityQueue } from "./priority-queue.ts";

dijkstra(start: T): Map<T, number> {
  const best = new Map<T, number>();
  const queue = new PriorityQueue<[T, number]>((a, b) => a[1] - b[1]);
  queue.push([start, 0]);

  while (!queue.isEmpty()) {
    const [vertex, cost] = queue.pop() as [T, number];
    if (best.has(vertex)) continue; // already settled with a cheaper cost

    best.set(vertex, cost);
    for (const [next, weight] of this.adjacency.get(vertex) ?? []) {
      if (!best.has(next)) queue.push([next, cost + weight]);
    }
  }
  return best;
}

សាកល្បងវា៖

console.log(km.dijkstra("A"));
// Map(4) { 'A' => 0, 'C' => 1, 'B' => 3, 'D' => 4 }

ថ្លៃថោកបំផុតទៅ D គឺ 4៖ A -> C -> B -> D គឺ 1 + 2 + 1។ នេះគឺជា queue ជំហានម្តងមួយៗ៖

pop (A, 0): settle A=0. Push (B, 4) and (C, 1).
pop (C, 1): settle C=1. Push (B, 3) and (D, 6).
pop (B, 3): settle B=3. Push (D, 4).
pop (B, 4): B is already settled. Skip.
pop (D, 4): settle D=4.
pop (D, 6): D is already settled. Skip.

សូមកត់សម្គាល់ថា B ចូលក្នុង queue ពីរដង ជាមួយថ្លៃ 4 និង 3។ Heap របស់យើងមិនអាចប្តូរ priority របស់ item បន្ទាប់ពី push បានទេ (នេះគឺជាលំហាត់ទី 7 នៃជំពូកមុន) ដូច្នេះយើង push ច្បាប់ចម្លងថ្មីដែលល្អជាង ហើយរំលងច្បាប់ចម្លងចាស់នៅពេលវាចេញមកចុងក្រោយ។ នោះហើយជាមូលហេតុដែលបន្ទាត់ if (best.has(vertex)) continue មាននៅទីនោះ។

ហេតុអ្វីវាដំណើរការ? លើកដំបូងដែល vertex ត្រូវបាន pop ថ្លៃរបស់វាគឺតូចបំផុតក្នុង queue ទាំងមូល។ ផ្លូវផ្សេងទៀតទៅកាន់វាត្រូវឆ្លងកាត់ vertex ដែលមានថ្លៃស្មើ ឬច្រើនជាង ហើយការបន្ថែម edge ច្រើនមិនអាចធ្វើឲ្យវាថោកជាងបានទេ ដរាបណាគ្មាន edge មាន weight អវិជ្ជមាន។ នោះគឺជាច្បាប់ធំតែមួយរបស់ Dijkstra។ ជាមួយ weight អវិជ្ជមាន អ្នកត្រូវការ algorithm ផ្សេង (Bellman-Ford)។ Vertex ដែលមិនអាចទៅដល់បានគ្រាន់តែបាត់ពីលទ្ធផល៖

km.addVertex("Z");
console.log(km.dijkstra("A").has("Z")); // false

ថ្លៃគឺ O((V + E) log V) ព្រោះ edge នីមួយៗអាច push item មួយ (ថ្លៃ log) ហើយ vertex នីមួយៗត្រូវបាន settle ម្តង។ បើគ្មាន heap (ស្កេនរកថោកបំផុតរាល់ពេល) វានឹងជា O(V²)។

ដើម្បីទទួលបាន path ផងដែរ (មិនមែនតែថ្លៃ) រក្សា cameFrom map ដូចក្នុងផ្នែកទី 7 ដោយធ្វើបច្ចុប្បន្នភាពវានៅពេលអ្នក push ផ្លូវដែលល្អជាង។ GPS navigation ប្រើគំនិតរបស់ Dijkstra (ជាធម្មតាជាមួយការបង្កើនល្បឿនដែលហៅថា A* ដែលបន្ថែមតម្រុយ "ទិសណាជាគោលដៅ" ទៅ priority)។

11. បញ្ហាសម្ភាសន៍លើ Grid និង Prerequisite

បញ្ហា graph ជាច្រើនមិនមើលទៅដូច graph ទេ។ ការរៀនមើលឃើញ graph ដែលលាក់ខ្លួនគឺជាពាក់កណ្តាលនៃជំនាញ។ សួរថា៖ តើ vertex ជាអ្វី ហើយ edge ជាអ្វី?

ចំនួនកោះ (Number of islands)។ Grid នៃ "1" (ដី) និង "0" (ទឹក)។ រាប់កោះ ដែលកោះគឺជាដីដែលភ្ជាប់ឡើងលើ ចុះក្រោម ឆ្វេង ឬស្តាំ។

const grid = [
  ["1", "1", "0", "0"],
  ["1", "0", "0", "1"],
  ["0", "0", "1", "1"],
  ["0", "0", "0", "0"],
];

function countIslands(grid: string[][]): number {
  const rows = grid.length;
  const cols = grid[0].length;
  const seen = new Set<string>();
  let islands = 0;

  const sink = (r: number, c: number): void => {
    if (r < 0 || c < 0 || r >= rows || c >= cols) return;
    if (grid[r][c] === "0" || seen.has(`${r},${c}`)) return;
    seen.add(`${r},${c}`);
    sink(r + 1, c);
    sink(r - 1, c);
    sink(r, c + 1);
    sink(r, c - 1);
  };

  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      if (grid[r][c] === "1" && !seen.has(`${r},${c}`)) {
        islands++; // a new, unseen piece of land: a new island
        sink(r, c); // mark all of it as seen
      }
    }
  }
  return islands;
}

console.log(countIslands(grid)); // 2

នៅទីនេះ cell នីមួយៗជា vertex ហើយគូ cell ដីដែលប៉ះគ្នានីមួយៗជា edge ដូច្នេះនេះគឺជា "រាប់ connected component" ពីផ្នែកទី 8 ដែលសរសេរជា DFS លើ grid។ សូមកត់សម្គាល់ថាមិនមាន object Graph ទាល់តែសោះ។ Grid គឺជា graph ហើយ (r + 1, c), (r - 1, c), (r, c + 1) និង (r, c - 1) គឺជា neighbor។ ថ្លៃគឺ O(rows × cols)។

កាលវិភាគវគ្គសិក្សា (Course schedule)។ មានវគ្គសិក្សា count ដែលដាក់លេខពី 0 ដល់ count - 1។ គូ [course, before] នីមួយៗមានន័យថាអ្នកត្រូវរៀន before មុន។ តើអ្នកអាចបញ្ចប់ទាំងអស់បានទេ? នេះគឺ "តើ dependency graph មាន cycle ឬទេ?" ដែល topologicalSort របស់យើងឆ្លើយ៖

function canFinish(count: number, prerequisites: [number, number][]): boolean {
  const g = new Graph<number>(true);
  for (let i = 0; i < count; i++) g.addVertex(i);
  for (const [course, before] of prerequisites) g.addEdge(before, course);
  return g.topologicalSort() !== null;
}

console.log(canFinish(2, [[1, 0]]));         // true  (take 0, then 1)
console.log(canFinish(2, [[1, 0], [0, 1]])); // false (each needs the other)

ផ្លូវខ្លីបំផុតក្នុង maze។ S គឺជាចំណុចចាប់ផ្តើម E គឺជាច្រកចេញ # គឺជាជញ្ជាំង។ តើផ្លូវចេញខ្លីបំផុតមានប៉ុន្មានជំហាន? ជំហានតិចបំផុតលើ grid ដែលគ្មាន weight គឺជាការងាររបស់ BFS៖

function shortestMaze(maze: string[]): number {
  const rows = maze.length;
  const cols = maze[0].length;
  let start = [0, 0];
  let end = [0, 0];

  maze.forEach((row, r) =>
    [...row].forEach((ch, c) => {
      if (ch === "S") start = [r, c];
      if (ch === "E") end = [r, c];
    }),
  );

  const dist = new Map<string, number>([[start.join(), 0]]);
  const queue = [start];

  for (let head = 0; head < queue.length; head++) {
    const [r, c] = queue[head];
    const here = dist.get(`${r},${c}`)!;
    if (r === end[0] && c === end[1]) return here;

    for (const [dr, dc] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
      const nr = r + dr;
      const nc = c + dc;
      const key = `${nr},${nc}`;
      if (nr < 0 || nc < 0 || nr >= rows || nc >= cols) continue;
      if (maze[nr][nc] === "#" || dist.has(key)) continue;
      dist.set(key, here + 1);
      queue.push([nr, nc]);
    }
  }
  return -1; // no way out
}

console.log(shortestMaze(["S.#.", "..#E", "...."])); // 6
console.log(shortestMaze(["S#E"]));                  // -1

ក្នុង maze ទីមួយ ជញ្ជាំងក្នុងជួរឈរទី 2 បង្ខំឲ្យអ្នកចុះទៅជួរដេកខាងក្រោម ហើយឡើងមកវិញ៖ ចុះ ចុះ ស្តាំ ស្តាំ ស្តាំ ឡើង គឺ 6 ជំហាន។ ក្នុង maze ទីពីរ ជញ្ជាំងរារាំងផ្លូវទាំងស្រុង ដូច្នេះចម្លើយគឺ -1។

បញ្ជីពិនិត្យសម្រាប់បញ្ហា graph ណាមួយ៖

  1. តើ vertex ជាអ្វី? តើ edge ជាអ្វី? Directed ឬអត់? Weighted ឬអត់?
  2. តើខ្ញុំត្រូវការ ជំហានតិចបំផុត (BFS) ថ្លៃថោកបំផុត (Dijkstra) អ្វីៗទាំងអស់ដែលអាចទៅដល់ (BFS ឬ DFS) លំដាប់ (topological sort) ឬ ក្រុម (component)?
  3. តើខ្ញុំត្រូវការ seen set ដើម្បីកុំឲ្យ loop ជារៀងរហូតឬទេ? (បាទ/ចាស ស្ទើរតែជានិច្ច។)

12. រូបមន្ត៖ graph.ts សម្រាប់ algolab

បង្កើត src/graph.ts ក្នុង algolab។ វា import PriorityQueue ពីជំពូកមុន៖

import { PriorityQueue } from "./priority-queue.ts";

export class Graph<T = string> {
  private adjacency = new Map<T, Map<T, number>>();

  constructor(private directed = false) {}

  addVertex(vertex: T): void {
    if (!this.adjacency.has(vertex)) this.adjacency.set(vertex, new Map());
  }

  addEdge(from: T, to: T, weight = 1): void {
    this.addVertex(from);
    this.addVertex(to);
    this.adjacency.get(from)!.set(to, weight);
    if (!this.directed) this.adjacency.get(to)!.set(from, weight);
  }

  hasEdge(from: T, to: T): boolean {
    return this.adjacency.get(from)?.has(to) ?? false;
  }

  vertices(): T[] {
    return [...this.adjacency.keys()];
  }

  neighbors(vertex: T): T[] {
    return [...(this.adjacency.get(vertex)?.keys() ?? [])];
  }

  // Visit everything reachable from start, nearest first.
  bfs(start: T): T[] {
    if (!this.adjacency.has(start)) return [];
    const order: T[] = [];
    const seen = new Set<T>([start]);
    const queue: T[] = [start];
    for (let head = 0; head < queue.length; head++) {
      const vertex = queue[head];
      order.push(vertex);
      for (const next of this.neighbors(vertex)) {
        if (!seen.has(next)) {
          seen.add(next);
          queue.push(next);
        }
      }
    }
    return order;
  }

  // Visit everything reachable from start, going as deep as possible first.
  dfs(start: T): T[] {
    const order: T[] = [];
    const seen = new Set<T>();
    const visit = (vertex: T): void => {
      seen.add(vertex);
      order.push(vertex);
      for (const next of this.neighbors(vertex)) {
        if (!seen.has(next)) visit(next);
      }
    };
    if (this.adjacency.has(start)) visit(start);
    return order;
  }

  // Fewest-edges path from a to b, or null if b is unreachable.
  shortestPath(from: T, to: T): T[] | null {
    if (!this.adjacency.has(from) || !this.adjacency.has(to)) return null;
    const cameFrom = new Map<T, T | null>([[from, null]]);
    const queue: T[] = [from];
    for (let head = 0; head < queue.length; head++) {
      const vertex = queue[head];
      if (vertex === to) break;
      for (const next of this.neighbors(vertex)) {
        if (!cameFrom.has(next)) {
          cameFrom.set(next, vertex);
          queue.push(next);
        }
      }
    }
    if (!cameFrom.has(to)) return null;
    const path: T[] = [];
    for (
      let v: T | null | undefined = to;
      v !== null && v !== undefined;
      v = cameFrom.get(v)
    ) {
      path.push(v);
    }
    return path.reverse();
  }

  // Groups of vertices that can reach each other (undirected graphs).
  connectedComponents(): T[][] {
    const seen = new Set<T>();
    const groups: T[][] = [];
    for (const vertex of this.vertices()) {
      if (seen.has(vertex)) continue;
      const group = this.bfs(vertex);
      for (const v of group) seen.add(v);
      groups.push(group);
    }
    return groups;
  }

  // Directed graphs: is there a loop you can follow forever?
  hasCycle(): boolean {
    const state = new Map<T, "visiting" | "done">();
    const visit = (vertex: T): boolean => {
      state.set(vertex, "visiting");
      for (const next of this.neighbors(vertex)) {
        const s = state.get(next);
        if (s === "visiting") return true;
        if (s === undefined && visit(next)) return true;
      }
      state.set(vertex, "done");
      return false;
    };
    for (const vertex of this.vertices()) {
      if (!state.has(vertex) && visit(vertex)) return true;
    }
    return false;
  }

  // Directed graphs: an order where every edge points forward.
  // Returns null when a cycle makes that impossible.
  topologicalSort(): T[] | null {
    const incoming = new Map<T, number>();
    for (const v of this.vertices()) incoming.set(v, 0);
    for (const v of this.vertices()) {
      for (const next of this.neighbors(v)) {
        incoming.set(next, incoming.get(next)! + 1);
      }
    }
    const ready: T[] = this.vertices().filter((v) => incoming.get(v) === 0);
    const order: T[] = [];
    for (let head = 0; head < ready.length; head++) {
      const vertex = ready[head];
      order.push(vertex);
      for (const next of this.neighbors(vertex)) {
        const left = incoming.get(next)! - 1;
        incoming.set(next, left);
        if (left === 0) ready.push(next);
      }
    }
    return order.length === this.adjacency.size ? order : null;
  }

  // Cheapest total weight from start to every reachable vertex.
  // Weights must not be negative.
  dijkstra(start: T): Map<T, number> {
    const best = new Map<T, number>();
    const queue = new PriorityQueue<[T, number]>((a, b) => a[1] - b[1]);
    queue.push([start, 0]);
    while (!queue.isEmpty()) {
      const [vertex, cost] = queue.pop() as [T, number];
      if (best.has(vertex)) continue; // already settled with a cheaper cost
      best.set(vertex, cost);
      for (const [next, weight] of this.adjacency.get(vertex) ?? []) {
        if (!best.has(next)) queue.push([next, cost + weight]);
      }
    }
    return best;
  }
}

ឥឡូវ test ក្នុង src/graph.test.ts៖

import { test } from "node:test";
import assert from "node:assert/strict";
import { Graph } from "./graph.ts";

function cambodia(): Graph {
  const g = new Graph();
  g.addEdge("Phnom Penh", "Kampong Cham");
  g.addEdge("Phnom Penh", "Kampot");
  g.addEdge("Phnom Penh", "Siem Reap");
  g.addEdge("Kampong Cham", "Kratie");
  g.addEdge("Siem Reap", "Battambang");
  g.addEdge("Kampot", "Kep");
  return g;
}

test("undirected edges go both ways", () => {
  const g = new Graph();
  g.addEdge("A", "B");
  assert.equal(g.hasEdge("A", "B"), true);
  assert.equal(g.hasEdge("B", "A"), true);
});

test("directed edges go one way", () => {
  const g = new Graph(true);
  g.addEdge("A", "B");
  assert.equal(g.hasEdge("A", "B"), true);
  assert.equal(g.hasEdge("B", "A"), false);
});

test("bfs visits nearest first, dfs goes deep first", () => {
  const g = cambodia();
  assert.deepEqual(g.bfs("Phnom Penh"), [
    "Phnom Penh", "Kampong Cham", "Kampot", "Siem Reap",
    "Kratie", "Kep", "Battambang",
  ]);
  assert.deepEqual(g.dfs("Phnom Penh"), [
    "Phnom Penh", "Kampong Cham", "Kratie", "Kampot",
    "Kep", "Siem Reap", "Battambang",
  ]);
});

test("shortestPath finds the fewest hops", () => {
  const g = cambodia();
  assert.deepEqual(g.shortestPath("Kep", "Battambang"), [
    "Kep", "Kampot", "Phnom Penh", "Siem Reap", "Battambang",
  ]);
  assert.equal(g.shortestPath("Kep", "Nowhere"), null);
});

test("connectedComponents groups separate islands", () => {
  const g = new Graph();
  g.addEdge("a", "b");
  g.addEdge("c", "d");
  g.addEdge("d", "e");
  g.addVertex("f");
  assert.deepEqual(g.connectedComponents(), [["a", "b"], ["c", "d", "e"], ["f"]]);
});

test("topologicalSort orders tasks and detects cycles", () => {
  const g = new Graph(true);
  g.addEdge("install", "build");
  g.addEdge("lint", "build");
  g.addEdge("build", "test");
  g.addEdge("build", "deploy");
  g.addEdge("test", "deploy");
  assert.deepEqual(g.topologicalSort(), [
    "install", "lint", "build", "test", "deploy",
  ]);
  assert.equal(g.hasCycle(), false);

  g.addEdge("deploy", "install");
  assert.equal(g.topologicalSort(), null);
  assert.equal(g.hasCycle(), true);
});

test("dijkstra finds the cheapest route, not the fewest hops", () => {
  const g = new Graph();
  g.addEdge("A", "B", 4);
  g.addEdge("A", "C", 1);
  g.addEdge("C", "B", 2);
  g.addEdge("B", "D", 1);
  g.addEdge("C", "D", 5);
  g.addVertex("Z");

  const best = g.dijkstra("A");
  assert.equal(best.get("B"), 3);
  assert.equal(best.get("D"), 4);
  assert.equal(best.has("Z"), false);
});

ដំណើរការវា៖

npx tsx --test

បន្ថែម export ទៅ src/index.ts៖

export * from "./graph.ts";

បន្ទាប់មកបង្កើត demo-graph.ts ក្នុង root នៃ project៖

import { Graph } from "./src/graph.ts";

const g = new Graph();
g.addEdge("Phnom Penh", "Kampong Cham");
g.addEdge("Phnom Penh", "Kampot");
g.addEdge("Phnom Penh", "Siem Reap");
g.addEdge("Kampong Cham", "Kratie");
g.addEdge("Siem Reap", "Battambang");
g.addEdge("Kampot", "Kep");

console.log("bfs:", g.bfs("Phnom Penh").join(", "));
console.log("dfs:", g.dfs("Phnom Penh").join(", "));
console.log("path:", g.shortestPath("Kep", "Battambang")?.join(" -> "));

const tasks = new Graph(true);
tasks.addEdge("install", "build");
tasks.addEdge("lint", "build");
tasks.addEdge("build", "test");
tasks.addEdge("build", "deploy");
tasks.addEdge("test", "deploy");
console.log("order:", tasks.topologicalSort()?.join(", "));
tasks.addEdge("deploy", "install");
console.log("cycle:", tasks.hasCycle());

const roads = new Graph();
roads.addEdge("A", "B", 4);
roads.addEdge("A", "C", 1);
roads.addEdge("C", "B", 2);
roads.addEdge("B", "D", 1);
roads.addEdge("C", "D", 5);
console.log("cheapest from A:", JSON.stringify([...roads.dijkstra("A")]));

ដំណើរការវា៖

npx tsx demo-graph.ts
bfs: Phnom Penh, Kampong Cham, Kampot, Siem Reap, Kratie, Kep, Battambang
dfs: Phnom Penh, Kampong Cham, Kratie, Kampot, Kep, Siem Reap, Battambang
path: Kep -> Kampot -> Phnom Penh -> Siem Reap -> Battambang
order: install, lint, build, test, deploy
cycle: true
cheapest from A: [["A",0],["C",1],["B",3],["D",4]]

13. សាកល្បងដោយខ្លួនឯង

  1. គូរពីរវិធី។ គូរ graph ដែលមាន edge A-B, A-C, B-D, C-D, D-E។ សរសេរវាជា adjacency list និងជា adjacency matrix។ មួយណាខ្លីជាងក្នុងការសរសេរ? តើវានៅតែខ្លីជាងនៅ vertex មួយលានឬទេ?
  2. មិត្តភក្តិរបស់មិត្តភក្តិ។ បន្ថែម method withinSteps(start, n) ដែលត្រឡប់គ្រប់ vertex ដែលឆ្ងាយពី start យ៉ាងច្រើន n edge ដោយប្រើ BFS។ (ជំនួយ៖ តាមដានចម្ងាយសម្រាប់ vertex នីមួយៗ ដូចឧទាហរណ៍ maze។)
  3. តើអាចទៅដល់ទេ? បន្ថែម canReach(from, to)។ តើគួរប្រើ BFS ឬ DFS? តើវាមានសារៈសំខាន់សម្រាប់ភាពត្រឹមត្រូវទេ? សម្រាប់ល្បឿនទេ?
  4. គូរលំដាប់។ សរសេរ dependencies សម្រាប់រូបមន្តម្ហូបមួយ៖ "bake ត្រូវការ mix និង preheat" "mix ត្រូវការ buy" "preheat មិនត្រូវការអ្វីទេ"។ តើ topologicalSort ត្រឡប់អ្វី? តើចម្លើយនោះជាចម្លើយត្រឹមត្រូវតែមួយគត់ឬទេ?
  5. សម្ភាសន៍៖ រកឃើញ cycle។ ដោយមាន directed graph ជា object { a: ["b"], b: ["c"], c: ["a"] } សរសេរ hasCycle ដោយគ្មាន class Graph។ តើផ្នែកណានៃ algorithm នឹងខូច ប្រសិនបើអ្នកប្រើ seen set ធម្មតាជំនួសឲ្យ state "visiting / done"? រក graph ដែលបោកវា។
  6. សម្ភាសន៍៖ word ladder។ ប្តូរ "cold" ទៅ "warm" ម្តងមួយអក្សរ ដែលរាល់ជំហានត្រូវតែជាពាក្យពិតពីបញ្ជី។ Vertex គឺជាពាក្យ ហើយពាក្យពីរត្រូវបានភ្ជាប់ប្រសិនបើវាខុសគ្នាមួយអក្សរ។ តើ algorithm ណារកឃើញ ladder ខ្លីបំផុត?
  7. សម្ភាសន៍៖ ជើងហោះហើរថោកបំផុត។ ដោយមានជើងហោះហើរជា triple [from, to, price] រកតម្លៃថោកបំផុតពីទីក្រុងមួយទៅទីក្រុងមួយទៀតជាមួយ Graph និង dijkstra។ បន្ទាប់មកសួរថា៖ ចុះបើអ្នកអាចប្រើជើងហោះហើរយ៉ាងច្រើន 2? ហេតុអ្វី Dijkstra ធម្មតាលែងដំណើរការដូចដែលមាន?

Graph គឺជារូបរាងទូទៅដែលនៅពីក្រោមរចនាសម្ព័ន្ធភាគច្រើនក្នុងស៊េរីនេះ ហើយ BFS, DFS និង priority queue គឺជាឧបករណ៍ដែលធ្វើឲ្យពួកវាមានប្រយោជន៍។ បន្ទាប់ក្នុង Trie និង Union-Find យើងនឹងមើល structure ឯកទេសពីរ៖ tree សម្រាប់ការស្វែងរក prefix លឿន និង autocomplete និងវិធីឆ្លាតក្នុងការឆ្លើយ "តើរបស់ពីរនេះភ្ជាប់គ្នាឬទេ?" ស្ទើរតែភ្លាមៗនៅពេលអ្នកបន្ថែមការតភ្ជាប់។

ទិញកាហ្វេជូនខ្ញុំ

ចូលចិត្តមេរៀននេះមែនទេ? សូមពិចារណាទិញកាហ្វេជូនខ្ញុំមួយកែវ ដើម្បីគាំទ្រការបង្កើតមាតិកាឥតគិតថ្លៃបន្ថែមទៀត!