Linked List
មាតិកា
- គ្រាប់ត្បូងនៅលើខ្សែ
- Node និង Pointer
- កន្លែងដែលអ្នកជួប Linked List នៅកន្លែងធ្វើការ
- Linked List ទល់នឹង Array
- ការដើរ ការស្វែងរក និងការបញ្ចូល
- ការលុប Node
- Doubly Linked List
- បញ្ហាបុរាណ៖ បញ្ច្រាស List
- បញ្ហាបុរាណ៖ ការរកឃើញ Cycle
- បញ្ហាបុរាណ៖ កណ្តាល Merge និង Node ទី N ពីចុង
- រូបមន្ត៖ linked-list.ts សម្រាប់ algolab
- សាកល្បងដោយខ្លួនឯង
1. គ្រាប់ត្បូងនៅលើខ្សែ
Array គឺជាជួរទូដាក់ឥវ៉ាន់៖ មានលេខរៀង ដាក់ជាប់គ្នា ហើយអ្នកអាចដើរត្រង់ទៅទូលេខ 500 បាន។ Linked list គឺជាល្បែងស្វែងរកកំណប់។ តម្រុយនីមួយៗប្រាប់អ្នកថាមានអ្វីនៅកន្លែងនេះ និងតម្រុយបន្ទាប់លាក់នៅឯណា។ ដើម្បីទៅដល់តម្រុយទី 500 អ្នកត្រូវតាមតម្រុយ 499 ជាមុនសិន។
នោះស្តាប់ទៅដូចជាប្រតិបត្តិការអាក្រក់ជាង ហើយសម្រាប់ការអានវាពិតជាអាក្រក់ជាង។ ប៉ុន្តែល្បែងស្វែងរកកំណប់មានអំណាចពិសេសមួយ។ ដើម្បីបន្ថែមតម្រុយថ្មីរវាងតម្រុយពីរដែលមានស្រាប់ អ្នកគ្រាន់តែប្តូរអ្វីដែលអ្នកជិតខាងចង្អុលទៅ។ គ្មានអ្វីផ្សេងត្រូវរើទេ ហើយគ្មានអ្វីត្រូវចម្លងទេ។ ក្នុងជំពូកមុន អ្នកបានឃើញថា shift() និង splice() លើ array ត្រូវរុញធាតុក្រោយៗទាំងអស់។ Linked list ធ្វើការងារដូចគ្នាក្នុងជំហានតែមួយ នៅពេលអ្នកឈរនៅកន្លែងត្រឹមត្រូវ។
ក្នុងជំពូកនេះ អ្នកនឹង៖
- បង្កើត linked list ពីសូន្យដោយប្រើ node និង pointer
- ឃើញយ៉ាងច្បាស់ថាពេលណាវាឈ្នះ array (និងពេលណាវាចាញ់)
- ដោះស្រាយបញ្ហា linked-list បួនដែលលេចឡើងម្តងហើយម្តងទៀតក្នុងការសម្ភាសន៍
- បន្ថែម class
LinkedListដែលបាន test ទៅalgolabដែលជំពូកបន្ទាប់ប្រើដើម្បីបង្កើត queue
បើអ្នកមិនទាន់រៀបចំ algolab ទេ សូមត្រឡប់ទៅ ហេតុអ្វី Data Structure និង Algorithm សំខាន់។ អ្វីៗទាំងអស់ខាងក្រោមសន្មតថាអ្នកមាន src/ និងពាក្យបញ្ជា npx tsx --test ពីជំពូកនោះ។
2. Node និង Pointer
Linked list ត្រូវបានបង្កើតពី object តូចៗហៅថា node។ Node នីមួយៗកាន់របស់ពីរ៖ តម្លៃ និង pointer (reference) ទៅ node បន្ទាប់។ Node ចុងក្រោយចង្អុលទៅ null មានន័យថា "គ្មានអ្វីបន្ទាប់ពីខ្ញុំ"។
head tail
| |
v v
[ 10 | * ]--->[ 20 | * ]--->[ 30 | * ]--->[ 40 | null ]
ក្នុង TypeScript node គឺជា class តូចមួយ៖
class ListNode<T> {
next: ListNode<T> | null = null;
constructor(public value: T) {}
}
ប្រភេទ ListNode<T> | null និយាយថា "ជា node បន្ទាប់ ឬគ្មានអ្វីទាំងអស់"។ TypeScript នឹងបង្ខំអ្នកឲ្យពិនិត្យ null មុនពេលតាម pointer ដែលការពារ bug មួយគ្រួសារទាំងមូល។
អ្នកអាចបង្កើត list ដោយដៃ៖
const a = new ListNode(10);
const b = new ListNode(20);
const c = new ListNode(30);
a.next = b;
b.next = c;
// a -> b -> c -> null
List គឺគ្រាន់តែជា node ដំបូង។ យើងហៅវាថា head ហើយអ្វីៗផ្សេងទៀតត្រូវបានទៅដល់ដោយតាម .next។ បើអ្នកបាត់ head អ្នកបាត់ list ទាំងមូល ព្រោះគ្មានអ្វីផ្សេងចង្អុលទៅ node ដំបូង។ បន្ទាប់មក garbage collection សម្អាតវាទាំងអស់។
យើងក៏រក្សា pointer ទីពីរទៅ node ចុងក្រោយ ហៅថា tail។ អ្នកនឹងឃើញបន្តិចទៀតថាហេតុអ្វីវាសំខាន់។
ក្នុង memory node អាចនៅកន្លែងណាក៏បាន។ មិនដូច array ទេ node មិនអង្គុយជាប់គ្នាទេ។ នីមួយៗជា object ដាច់ដោយឡែក ដែលចង្អុលទៅបន្ទាប់ដោយចៃដន្យ។ រឿងនេះមានផលវិបាកធំពីរ៖
- អ្នកមិនអាចលោតទៅធាតុទី
iបានទេ។ អ្នកត្រូវចាប់ផ្តើមពី head ហើយដើរ។ - ការបញ្ចូល ឬការលុបមិនផ្លាស់ទី node ផ្សេងទេ។ អ្នកគ្រាន់តែប្តូរ pointer។
ផលវិបាកទីបីដែលស្ងប់ស្ងាត់ជាង៖ CPU ទំនើបចូលចិត្តអាន memory ដែលនៅជាប់គ្នា (array) ព្រោះវាផ្ទុកបំណែកទាំងមូលក្នុងពេលតែមួយ។ ការលោតរវាង node ដែលខ្ចាត់ខ្ចាយយឺតជាងក្នុងការអនុវត្តជាក់ស្តែង ទោះបី Big-O ដូចគ្នាក៏ដោយ។
3. កន្លែងដែលអ្នកជួប Linked List នៅកន្លែងធ្វើការ
អ្នកនឹងកម្រសរសេរ class ListNode ក្នុងគម្រោងវេបប្រចាំថ្ងៃណាស់ ប៉ុន្តែគំនិតនេះមាននៅគ្រប់ទីកន្លែង៖
- ប្រវត្តិ browser។ ថយក្រោយ និងទៅមុខដើរកាត់ chain នៃទំព័រ ម្តងមួយជំហាន។ Doubly linked list សមល្អឥតខ្ចោះ។
- Undo/redo និង playlist។ ចម្រៀង "មុន" និង "បន្ទាប់" គឺជា doubly linked list។ កម្មវិធីតន្ត្រីក៏បញ្ចូល "ចាក់បន្ទាប់" នៅកណ្តាលដោយមិនបង្កើត queue ឡើងវិញ។
- LRU cache។ Cache ដែលបណ្តេញធាតុដែលប្រើយូរបំផុតចេញ រក្សាធាតុក្នុង doubly linked list ដូច្នេះវាអាចផ្លាស់ធាតុមកខាងមុខក្នុង O(1)។ នេះគឺជាសំណួរសម្ភាសន៍ដ៏ល្បីបំផុតមួយ។
- ប្រព័ន្ធប្រតិបត្តិការ និង memory allocator។ Block memory ទំនេរត្រូវបានតាមដានក្នុង linked list។
- Git។ រាល់ commit ចង្អុលទៅ commit មេរបស់វា ដូច្នេះ branch គឺជា linked list នៃ commit។
- Structure ផ្សេងទៀត។ Hash map ជាមួយ chaining (ជំពូកទី 3) queue (ជំពូកបន្ទាប់) adjacency list របស់ graph និង blockchain សុទ្ធតែបង្កើតពី node ដែលចង្អុលទៅ node ផ្សេងទៀត។
ទោះបីអ្នកមិនដែលបង្កើតមួយសម្រាប់ production ក៏ដោយ អ្នកសម្ភាសន៍ចូលចិត្ត linked list។ ពួកគេសាកល្បងថាតើអ្នកអាចគិតជា pointer ដោយមិនបាត់បង់ដានរបស់ chain បានឬអត់។
4. Linked List ទល់នឹង Array
នេះគឺជាថ្លៃចំណាយដាក់ក្បែរគ្នា។ n គឺជាចំនួនធាតុ។ "Singly linked ជាមួយ pointer head និង tail" គឺជាអ្វីដែលយើងបង្កើតក្នុងជំពូកនេះ។
| ប្រតិបត្តិការ | Array | Singly linked list |
|---|---|---|
អានធាតុនៅ index i | O(1) | O(n) |
| បន្ថែមនៅចុង | O(1) amortized | O(1) (ជាមួយ tail) |
| បន្ថែមនៅដើម | O(n) | O(1) |
| លុបពីដើម | O(n) | O(1) |
| លុបពីចុង | O(1) | O(n) |
| បញ្ចូលនៅកណ្តាល (ដឹងទីតាំង) | O(n) | O(1) |
| រកតម្លៃមួយ | O(n) | O(n) |
| Memory ក្នុងមួយធាតុ | គ្រាន់តែតម្លៃ | តម្លៃបូក pointer |
អានជួរ "បញ្ចូលនៅកណ្តាល" ដោយប្រុងប្រយ័ត្ន។ វានិយាយថា "ដឹងទីតាំង"។ ដើម្បីបញ្ចូលបន្ទាប់ពី node ទី 500 អ្នកនៅតែត្រូវ ដើរ 500 ជំហានដើម្បីទៅដល់ទីនោះ។ ការប្តូរ pointer ពិតប្រាកដគឺ O(1) ប៉ុន្តែការរកកន្លែងគឺ O(n)។ Linked list ឈ្នះតែនៅពេលដែលអ្នកកាន់ reference ទៅ node រួចហើយ ឧទាហរណ៍ពេល cache ផ្លាស់ធាតុដែលវាទើបតែរកឃើញ។
ការលុបពីចុងគឺ O(n) សម្រាប់ singly linked list ព្រោះអ្នកត្រូវការ node មុន node ចុងក្រោយ ហើយវិធីតែមួយគត់ដើម្បីរកវាគឺដើរពី head។ (Doubly linked list ដោះស្រាយរឿងនោះ សូមមើលផ្នែកទី 7។)
ដូច្នេះពេលណាអ្នកគួរប្រើ linked list?
- អ្នកបន្ថែម និងលុបនៅ ខាងមុខ គ្រប់ពេល (queue ឬ stack)។
- អ្នកត្រូវការបញ្ចូលធាតុចូល និងចេញ ហើយអ្នក កាន់ node រួចហើយ។
- អ្នកមិនត្រូវចំណាយលើការចម្លង array ធំឡើយ សូម្បីតែម្តងម្កាល។
ហើយពេលណាមិនគួរប្រើ? ស្ទើរតែជានិច្ច ប្រសិនបើអ្នកត្រូវការចូលប្រើតាម index ឬ iterate លើធាតុជាច្រើន។ ក្នុង JavaScript array ធម្មតាត្រូវបាន optimize យ៉ាងខ្លាំង ហើយសម្រាប់ទំហំតូច និងមធ្យម វាតែងតែឈ្នះ list ដែលសរសេរដោយដៃ ទោះបី Big-O និយាយថាវាគួរចាញ់ក៏ដោយ។ ចាត់ទុក linked list ជាឧបករណ៍ដែលអ្នកយល់ មិនមែនជាជម្រើសលំនាំដើមរបស់អ្នកទេ។
5. ការដើរ ការស្វែងរក និងការបញ្ចូល
ស្ទើរតែគ្រប់ប្រតិបត្តិការ linked-list ចាប់ផ្តើមជាមួយ loop ដែលដើរកាត់ chain។ ទន្ទេញរូបរាងនេះ៖
let node = head;
while (node !== null) {
// do something with node.value
node = node.next;
}
តោះដាក់ផ្នែកទាំងនេះចូលក្នុង class។ វារក្សា head, tail និង count ដូច្នេះការសួររកទំហំគឺ O(1)៖
class LinkedList<T> {
head: ListNode<T> | null = null;
tail: ListNode<T> | null = null;
private count = 0;
get size(): number {
return this.count;
}
}
ការបន្ថែមនៅខាងមុខ
prepend បង្កើត node ថ្មី ចង្អុលវាទៅ head ចាស់ ហើយធ្វើឲ្យវាជា head ថ្មី៖
prepend(value: T): this {
const node = new ListNode(value);
node.next = this.head;
this.head = node;
if (this.tail === null) this.tail = node; // list was empty
this.count++;
return this;
}
លំដាប់នៃបន្ទាត់ pointer ទាំងពីរសំខាន់។ បើអ្នកសរសេរ this.head = node មុន អ្នកនឹងបាត់ reference តែមួយគត់ទៅ list ចាស់។ ជានិច្ចកាល ភ្ជាប់ node ថ្មីទៅអ្វីដែលនៅសល់ជាមុន ហើយបន្ទាប់មកទើបផ្លាស់ head។
ការត្រឡប់ this អនុញ្ញាតឲ្យអ្នកភ្ជាប់ការហៅជាខ្សែ៖ list.prepend(2).prepend(1)។
ការបន្ថែមនៅចុង
បើគ្មាន pointer tail append នឹងដើរកាត់ list ទាំងមូល៖ O(n)។ ជាមួយមួយ វាគឺជាជំហានតែមួយ៖
append(value: T): this {
const node = new ListNode(value);
if (this.tail === null) {
this.head = node; // list was empty
} else {
this.tail.next = node;
}
this.tail = node;
this.count++;
return this;
}
ពីរករណី៖ list ទទេ (node ថ្មីក្លាយជាទាំង head និង tail) និង list មិនទទេ (tail ចាស់ចង្អុលទៅ node ថ្មី ដែលក្លាយជា tail)។ ករណីពិសេស list ទទេគឺជាប្រភពលេខមួយនៃ bug linked list។ រាល់ពេលអ្នកសរសេរ method សូមសួរថា "បើ list ទទេអ្វីនឹងកើតឡើង?" និង "បើវាមានធាតុតែមួយអ្វីនឹងកើតឡើង?"។
ការអានតាម index
get(index: number): T | undefined {
if (index < 0 || index >= this.count) return undefined;
let node = this.head;
for (let i = 0; i < index; i++) node = node!.next;
return node!.value;
}
នេះគឺ O(n)៖ ថ្លៃកើនឡើងតាម index។ ! បន្ទាប់ពី node ប្រាប់ TypeScript ថា "ខ្ញុំបានពិនិត្យដែនហើយ នេះមិនមែន null ទេ"។
ការបញ្ចូលនៅកណ្តាល
ដើម្បីបញ្ចូលនៅទីតាំង index ឈប់នៅ node មុន ទីតាំងនោះ បន្ទាប់មកភ្ជាប់ pointer ពីរឡើងវិញ៖
before: prev --> X
after: prev --> NEW --> X
insertAt(index: number, value: T): void {
if (index < 0 || index > this.count) {
throw new RangeError(`Index ${index} out of range`);
}
if (index === 0) {
this.prepend(value);
return;
}
if (index === this.count) {
this.append(value);
return;
}
let prev = this.head!;
for (let i = 0; i < index - 1; i++) prev = prev.next!;
const node = new ListNode(value);
node.next = prev.next; // new node points to the rest...
prev.next = node; // ...then the previous node points to it
this.count++;
}
ម្តងទៀតអំពីលំដាប់៖ ដំបូង node.next = prev.next បន្ទាប់មក prev.next = node។ ប្តូរបន្ទាត់ទាំងពីរនេះ ហើយ prev.next នឹងចង្អុលទៅ node ថ្មីរួចហើយ ដូច្នេះ node ថ្មីនឹងចង្អុលទៅខ្លួនវាផ្ទាល់ ហើយ list ដែលនៅសល់នឹងបាត់បង់។
6. ការលុប Node
ដើម្បីលុប node ធ្វើឲ្យ node មុន វាលោតរំលងវា៖
before: prev --> TARGET --> after
after: prev -----------------> after
គោលដៅត្រូវបានទុកចោលដោយគ្មាននរណាចង្អុលទៅវា ហើយ garbage collector ប្រមូលវាមកវិញ។ ការលុបពីខាងមុខងាយស្រួល ព្រោះគ្មាន node មុន៖
removeFirst(): T | undefined {
if (this.head === null) return undefined;
const node = this.head;
this.head = node.next;
if (this.head === null) this.tail = null; // list became empty
this.count--;
return node.value;
}
សូមកត់សម្គាល់បន្ទាត់ if (this.head === null) this.tail = null។ បើអ្នកលុប node ចុងក្រោយដែលនៅសល់ ហើយភ្លេចវា tail នឹងបន្តចង្អុលទៅ node ដែលស្លាប់ ហើយ append បន្ទាប់នឹងភ្ជាប់ទៅកន្លែងខុស។
ការលុបពីចុងគឺពិបាកបំផុត។ យើងត្រូវរក node មុន tail ហើយ singly linked list អាចដើរបានតែទៅមុខ៖
removeLast(): T | undefined {
if (this.head === null) return undefined;
if (this.head === this.tail) return this.removeFirst();
let prev = this.head;
while (prev.next !== this.tail) prev = prev.next!;
const value = this.tail!.value;
prev.next = null;
this.tail = prev;
this.count--;
return value;
}
វាគឺ O(n) ទោះបីយើងមាន pointer tail ក៏ដោយ។ Pointer tail ជួយយើងទៅដល់ node ចុងក្រោយ ប៉ុន្តែមិនមែន node មុនវាទេ។
ការលុបតម្លៃពីកណ្តាលដើរជាមួយអថេរ ពីរ៖ node (មួយដែលយើងកំពុងមើល) និង prev (មួយនៅពីក្រោយវា)៖
remove(value: T): boolean {
let prev: ListNode<T> | null = null;
let node = this.head;
while (node !== null) {
if (node.value === value) {
if (prev === null) {
this.removeFirst();
} else {
prev.next = node.next;
if (node === this.tail) this.tail = prev;
this.count--;
}
return true;
}
prev = node;
node = node.next;
}
return false;
}
មានបីករណីលាក់នៅទីនោះ៖ node ដំបូង (គ្មាន prev) node កណ្តាល និង node ចុងក្រោយ (ដូច្នេះ tail ត្រូវថយមក prev)។ ការសរសេរករណីទាំងនេះចេញដូចនេះ មុនពេលសរសេរកូដ គឺជាល្បិចដើម្បីសរសេរកូដ pointer ត្រឹមត្រូវតាំងពីលើកដំបូង។
7. Doubly Linked List
Doubly linked list ផ្តល់ឲ្យ node គ្រប់ៗនូវ pointer ទីពីរ prev ដែលចង្អុលថយក្រោយ៖
null <-- [10] <--> [20] <--> [30] --> null
class DNode<T> {
prev: DNode<T> | null = null;
next: DNode<T> | null = null;
constructor(public value: T) {}
}
Pointer បន្ថែមចំណាយ memory ហើយរាល់ការបញ្ចូល និងការលុបត្រូវកែ pointer បួន ជំនួសឲ្យពីរ។ ជាការដោះដូរ អ្នកទទួលបាន៖
- ការដើរទាំងពីរទិស (browser ថយក្រោយ និងទៅមុខ)
- ការលុប node ក្នុង O(1) នៅពេលអ្នកកាន់ node ខ្លួនឯង សូម្បីតែពីចុង ព្រោះ
node.prevនៅទីនោះ។
class DoublyLinkedList<T> {
head: DNode<T> | null = null;
tail: DNode<T> | null = null;
size = 0;
append(value: T): DNode<T> {
const node = new DNode(value);
node.prev = this.tail;
if (this.tail) this.tail.next = node;
else this.head = node;
this.tail = node;
this.size++;
return node; // hand back the node so callers can remove it later
}
// O(1) when you already hold the node
removeNode(node: DNode<T>): void {
if (node.prev) node.prev.next = node.next;
else this.head = node.next;
if (node.next) node.next.prev = node.prev;
else this.tail = node.prev;
node.prev = node.next = null;
this.size--;
}
}
សូមកត់សម្គាល់ថា append ត្រឡប់ node។ នោះគឺជាល្បិចនៅពីក្រោយ LRU cache៖ hash map ពី key ទៅ node ផ្តល់ការស្វែងរក O(1) ហើយ doubly linked list ផ្តល់ "ដក node នេះចេញ ហើយដាក់វានៅខាងមុខ" ក្នុង O(1)។ អ្នកនឹងប្រើការរួមបញ្ចូលនោះក្នុងលំហាត់។
នេះគឺជា removeNode ក្នុងសកម្មភាព៖
const list = new DoublyLinkedList<string>();
list.append("a");
const b = list.append("b");
list.append("c");
list.removeNode(b);
// list is now a <-> c
8. បញ្ហាបុរាណ៖ បញ្ច្រាស List
"បញ្ច្រាស singly linked list" គឺជាសំណួរសម្ភាសន៍ linked-list ទូទៅបំផុត។ គំនិត៖ ដើរកាត់ list ម្តង ហើយបត់ pointer នីមួយៗឲ្យវាចង្អុល ថយក្រោយ ជំនួសឲ្យទៅមុខ។
before: 1 -> 2 -> 3 -> null
after: null <- 1 <- 2 <- 3 (head is now 3)
អ្នកត្រូវការអថេរបី៖ prev (ផ្នែកដែលបានបញ្ច្រាសរួច) node (មួយដែលយើងកំពុងកែ) និង next (បានរក្សាទុក ដើម្បីកុំឲ្យយើងបាត់ list ដែលនៅសល់ នៅពេលយើងសរសេរជាន់លើ node.next)៖
reverse(): this {
let prev: ListNode<T> | null = null;
let node = this.head;
this.tail = this.head; // the old head becomes the new tail
while (node !== null) {
const next: ListNode<T> | null = node.next; // 1. remember the rest
node.next = prev; // 2. flip the pointer
prev = node; // 3. step forward
node = next;
}
this.head = prev;
return this;
}
តាមដានវាលើ 1 -> 2 -> 3៖
| ជំហាន | prev | node | next | List រហូតដល់ពេលនេះ |
|---|---|---|---|---|
| start | null | 1 | ||
| 1 | 1 | 2 | 2 | 1 -> null |
| 2 | 2 | 3 | 3 | 2 -> 1 -> null |
| 3 | 3 | null | null | 3 -> 2 -> 1 -> null |
នៅពេល node ក្លាយជា null នោះ prev គឺជា head ថ្មី។ ការបញ្ច្រាសចំណាយពេល O(n) និង space បន្ថែម O(1) ព្រោះវាប្តូរ pointer នៅនឹងកន្លែង ហើយមិនបង្កើត node ថ្មីទេ។
កំហុសទូទៅគឺភ្លេចរក្សាទុក next មុនពេលសរសេរជាន់លើ node.next។ បន្ទាប់មក node = node.next គ្រាន់តែត្រឡប់ទៅកន្លែងដែលអ្នកមក ហើយ loop មិនចប់ឡើយ។
កំណែ recursive គឺជាលំហាត់ល្អសម្រាប់ជំពូកទី 6 ប៉ុន្តែ loop គឺជាអ្វីដែលអ្នកសម្ភាសន៍រំពឹងមុន។
9. បញ្ហាបុរាណ៖ ការរកឃើញ Cycle
ចុះបើ next របស់ node ចង្អុលត្រឡប់ទៅ node មុន?
1 -> 2 -> 3 -> 4
^ |
|____|
ការដើរ list បែបនេះជាមួយ while (node !== null) វិលជារៀងរហូត។ Bug បែបនេះអាចបង្កក program ដូច្នេះយើងត្រូវការវិធីដើម្បីរកឃើញវា។
គំនិតដំបូង: ចងចាំ node ទាំងអស់ដែលយើងបានឃើញក្នុង Set។ បើយើងជួបមួយពីរដង នោះមាន cycle។ វាដំណើរការ ហើយចំណាយពេល O(n) ប៉ុន្តែក៏ចំណាយ memory បន្ថែម O(n) ដែរ។
គំនិតប្រសើរជាង Floyd's tortoise and hare (អណ្តើក និងទន្សាយ): ប្រើ pointer ពីរ។ មួយយឺត (អណ្តើក) ដើរម្តងមួយជំហាន។ មួយលឿន (ទន្សាយ) ដើរពីរជំហាន។ បើគ្មាន cycle ទន្សាយទៅដល់ចុង។ បើមាន cycle ទន្សាយចូលវាមុន ហើយបន្តរត់ជុំវិញ ហើយនៅទីបំផុតតាមទាន់អណ្តើកពីក្រោយ។ ពួកវាធ្លាក់លើ node ដូចគ្នា ដូច្នេះយើងដឹងថាមាន cycle។
function hasCycle<T>(head: ListNode<T> | null): boolean {
let slow = head;
let fast = head;
while (fast !== null && fast.next !== null) {
slow = slow!.next;
fast = fast.next.next;
if (slow === fast) return true;
}
return false;
}
ពេលវេលា O(n) space បន្ថែម O(1)។ ព័ត៌មានលម្អិតពីរសំខាន់៖
- Loop ពិនិត្យ
fast !== null && fast.next !== nullព្រោះទន្សាយលោតពីរ node ហើយមិនត្រូវជាន់ចេញពីចុងឡើយ។ - យើងប្រៀបធៀប node (
===លើ reference) មិនមែនតម្លៃទេ។ Node ពីរផ្សេងគ្នាអាចកាន់តម្លៃដូចគ្នា។
សាកល្បងវា៖
const list = LinkedList.from(["a", "b", "c", "d"]);
console.log(hasCycle(list.head)); // false
list.tail!.next = list.head!.next; // d now points back to b
console.log(hasCycle(list.head)); // true
ត្រូវប្រុងប្រយ័ត្ន៖ បន្ទាប់ពីអ្នកបង្កើត cycle ដោយចេតនា កុំហៅ toArray() ឬ method ណាដែលដើរ list រហូតដល់ចុង។ វាមិនចប់ឡើយ។
ហេតុអ្វីវាដំណើរការ? ស្រមៃអ្នករត់ពីរនាក់លើផ្លូវរង្វង់។ អ្នកលឿនជាងទទួលបានមួយជំហានក្នុងមួយជុំលើអ្នកយឺតជាង។ មិនយូរមិនឆាប់ គម្លាតក្លាយជាសូន្យ។ គម្លាតរួមតូចចុះបានតែមួយជំហានរាល់ពេល ដូច្នេះវាមិនអាចលោតរំលងសូន្យបានទេ។
10. បញ្ហាបុរាណ៖ កណ្តាល Merge និង Node ទី N ពីចុង
គំនិត two-pointer ដូចគ្នាដោះស្រាយបញ្ហាបន្ថែមទៀត។
រកកណ្តាល
ផ្លាស់ទី fast លឿនជាង slow ពីរដង។ នៅពេល fast ទៅដល់ចុង slow នៅកណ្តាល។ Pass តែមួយ មិនចាំបាច់ដឹងប្រវែងទេ៖
function findMiddle<T>(head: ListNode<T> | null): ListNode<T> | null {
let slow = head;
let fast = head;
while (fast !== null && fast.next !== null) {
slow = slow!.next;
fast = fast.next.next;
}
return slow;
}
សម្រាប់ 1, 2, 3 វាត្រឡប់ node ដែលមាន 2។ សម្រាប់ 1, 2, 3, 4 មានកណ្តាលពីរ ហើយកំណែនេះត្រឡប់មួយទីពីរ គឺ 3។ ចូរប្រាប់ជម្រើសរបស់អ្នកឲ្យឮៗក្នុងការសម្ភាសន៍។
Merge list ពីរដែលបានតម្រៀប
ដោយមាន list ពីរដែលស្ថិតក្នុងលំដាប់កើនរួចហើយ បង្កើត list តម្រៀបតែមួយដោយភ្ជាប់ node ដែលមានស្រាប់ឡើងវិញ។ នេះគឺជាជំហាន "merge" ដូចគ្នាដែល merge sort ប្រើ (អ្នកនឹងជួបវាក្នុងជំពូក sorting)។
ល្បិចគឺ dummy node៖ node ក្លែងក្លាយដែលដាក់មុនលទ្ធផលពិត។ វាលុបករណីពិសេសនៃ "តើ head នៃលទ្ធផលជាអ្វី?" ព្រោះអ្នកអាចភ្ជាប់ទៅ tail.next ជានិច្ច។
function mergeSorted(
a: ListNode<number> | null,
b: ListNode<number> | null,
): ListNode<number> | null {
const dummy = new ListNode<number>(0);
let tail = dummy;
while (a !== null && b !== null) {
if (a.value <= b.value) {
tail.next = a;
a = a.next;
} else {
tail.next = b;
b = b.next;
}
tail = tail.next;
}
tail.next = a ?? b; // attach whatever is left over
return dummy.next;
}
ដើរកាត់ 1 -> 4 -> 6 និង 2 -> 3 -> 7 -> 8៖
| ប្រៀបធៀប | យក | លទ្ធផលរហូតដល់ពេលនេះ |
|---|---|---|
| 1 vs 2 | 1 | 1 |
| 4 vs 2 | 2 | 1, 2 |
| 4 vs 3 | 3 | 1, 2, 3 |
| 4 vs 7 | 4 | 1, 2, 3, 4 |
| 6 vs 7 | 6 | 1, 2, 3, 4, 6 |
| (list ទីមួយអស់) | ដែលនៅសល់នៃទីពីរ | 1, 2, 3, 4, 6, 7, 8 |
ពេលវេលាគឺ O(n + m) សម្រាប់ list ប្រវែង n និង m ហើយ space បន្ថែមគឺ O(1) ព្រោះមិនបង្កើត node ថ្មីទេ។
ការប្រើ <= ជំនួស < យកពី a នៅពេលស្មើគ្នា ដែលរក្សា merge ឲ្យ stable៖ ធាតុស្មើគ្នាស្ថិតក្នុងលំដាប់ដើមរបស់វា។ Stability ក្លាយជារឿងសំខាន់ក្នុងជំពូក sorting។
លុប node ទី n ពីចុង
"លុប node ទី 2 ពីចុង" ដោយមិនរាប់ប្រវែងជាមុន។ ប្រើ pointer ពីរដែលមានគម្លាតថេរ។ ផ្លាស់ lead ទៅមុខ n ជំហាន បន្ទាប់មកផ្លាស់ទាំងពីររហូតដល់ lead ទៅដល់ node ចុងក្រោយ។ ឥឡូវ lag នៅត្រឹមមុន node ដែលត្រូវលុប។
function removeNthFromEnd<T>(
head: ListNode<T> | null,
n: number,
): ListNode<T> | null {
const dummy = new ListNode<T>(undefined as T);
dummy.next = head;
let lead: ListNode<T> | null = dummy;
let lag: ListNode<T> = dummy;
for (let i = 0; i < n; i++) lead = lead!.next;
while (lead!.next !== null) {
lead = lead!.next;
lag = lag.next!;
}
lag.next = lag.next!.next;
return dummy.next;
}
Dummy node ផ្តល់ផលម្តងទៀត៖ បើ n ស្មើប្រវែង list node ដែលត្រូវលុបគឺ head ហើយ lag គឺ dummy ដូច្នេះមិនត្រូវការករណីពិសេសទេ។ ការលុប node ទី 2 ពីចុងនៃ 1, 2, 3, 4, 5 ផ្តល់ 1, 2, 3, 5។
បញ្ហាក្នុងជំពូកនេះធ្វើតាមលំនាំដែលគួរចងចាំ៖
| បញ្ហា | លំនាំ |
|---|---|
| Reverse | pointer បី៖ prev, node, next |
| Cycle | pointer យឺត និងលឿន |
| Middle | pointer យឺត និងលឿន |
| Merge | dummy head បូក tail ដែលផ្លាស់ទី |
| Nth from end | pointer ពីរដែលមានគម្លាតថេរ បូក dummy head |
11. រូបមន្ត៖ linked-list.ts សម្រាប់ algolab
បង្កើត src/linked-list.ts ក្នុង algolab។ វាប្រមូលអ្វីៗទាំងអស់ពីជំពូកនេះ ហើយ Queue ក្នុងជំពូកបន្ទាប់បង្កើតពីលើវា៖
export class ListNode<T> {
next: ListNode<T> | null = null;
constructor(public value: T) {}
}
export class LinkedList<T> implements Iterable<T> {
head: ListNode<T> | null = null;
tail: ListNode<T> | null = null;
private count = 0;
get size(): number {
return this.count;
}
// O(1)
prepend(value: T): this {
const node = new ListNode(value);
node.next = this.head;
this.head = node;
if (this.tail === null) this.tail = node;
this.count++;
return this;
}
// O(1) អរគុណ pointer tail
append(value: T): this {
const node = new ListNode(value);
if (this.tail === null) {
this.head = node;
} else {
this.tail.next = node;
}
this.tail = node;
this.count++;
return this;
}
// O(n)
get(index: number): T | undefined {
if (index < 0 || index >= this.count) return undefined;
let node = this.head;
for (let i = 0; i < index; i++) node = node!.next;
return node!.value;
}
// O(n)
insertAt(index: number, value: T): void {
if (index < 0 || index > this.count) {
throw new RangeError(`Index ${index} out of range`);
}
if (index === 0) {
this.prepend(value);
return;
}
if (index === this.count) {
this.append(value);
return;
}
let prev = this.head!;
for (let i = 0; i < index - 1; i++) prev = prev.next!;
const node = new ListNode(value);
node.next = prev.next;
prev.next = node;
this.count++;
}
// O(1)
removeFirst(): T | undefined {
if (this.head === null) return undefined;
const node = this.head;
this.head = node.next;
if (this.head === null) this.tail = null;
this.count--;
return node.value;
}
// O(n)៖ យើងត្រូវរក node មុន tail
removeLast(): T | undefined {
if (this.head === null) return undefined;
if (this.head === this.tail) return this.removeFirst();
let prev = this.head;
while (prev.next !== this.tail) prev = prev.next!;
const value = this.tail!.value;
prev.next = null;
this.tail = prev;
this.count--;
return value;
}
// O(n)
remove(value: T): boolean {
let prev: ListNode<T> | null = null;
let node = this.head;
while (node !== null) {
if (node.value === value) {
if (prev === null) {
this.removeFirst();
} else {
prev.next = node.next;
if (node === this.tail) this.tail = prev;
this.count--;
}
return true;
}
prev = node;
node = node.next;
}
return false;
}
// O(n)
indexOf(value: T): number {
let i = 0;
for (const v of this) {
if (v === value) return i;
i++;
}
return -1;
}
// O(n) នៅនឹងកន្លែង
reverse(): this {
let prev: ListNode<T> | null = null;
let node = this.head;
this.tail = this.head;
while (node !== null) {
const next: ListNode<T> | null = node.next;
node.next = prev;
prev = node;
node = next;
}
this.head = prev;
return this;
}
*[Symbol.iterator](): Iterator<T> {
let node = this.head;
while (node !== null) {
yield node.value;
node = node.next;
}
}
toArray(): T[] {
return [...this];
}
static from<T>(values: Iterable<T>): LinkedList<T> {
const list = new LinkedList<T>();
for (const v of values) list.append(v);
return list;
}
}
// Floyd's tortoise and hare
export function hasCycle<T>(head: ListNode<T> | null): boolean {
let slow = head;
let fast = head;
while (fast !== null && fast.next !== null) {
slow = slow!.next;
fast = fast.next.next;
if (slow === fast) return true;
}
return false;
}
export function findMiddle<T>(
head: ListNode<T> | null,
): ListNode<T> | null {
let slow = head;
let fast = head;
while (fast !== null && fast.next !== null) {
slow = slow!.next;
fast = fast.next.next;
}
return slow;
}
export function mergeSorted(
a: ListNode<number> | null,
b: ListNode<number> | null,
): ListNode<number> | null {
const dummy = new ListNode<number>(0);
let tail = dummy;
while (a !== null && b !== null) {
if (a.value <= b.value) {
tail.next = a;
a = a.next;
} else {
tail.next = b;
b = b.next;
}
tail = tail.next;
}
tail.next = a ?? b;
return dummy.next;
}
export function removeNthFromEnd<T>(
head: ListNode<T> | null,
n: number,
): ListNode<T> | null {
const dummy = new ListNode<T>(undefined as T);
dummy.next = head;
let lead: ListNode<T> | null = dummy;
let lag: ListNode<T> = dummy;
for (let i = 0; i < n; i++) lead = lead!.next;
while (lead!.next !== null) {
lead = lead!.next;
lag = lag.next!;
}
lag.next = lag.next!.next;
return dummy.next;
}
export class DNode<T> {
prev: DNode<T> | null = null;
next: DNode<T> | null = null;
constructor(public value: T) {}
}
export class DoublyLinkedList<T> {
head: DNode<T> | null = null;
tail: DNode<T> | null = null;
size = 0;
append(value: T): DNode<T> {
const node = new DNode(value);
node.prev = this.tail;
if (this.tail) this.tail.next = node;
else this.head = node;
this.tail = node;
this.size++;
return node;
}
// O(1) នៅពេលអ្នកកាន់ node រួចហើយ
removeNode(node: DNode<T>): void {
if (node.prev) node.prev.next = node.next;
else this.head = node.next;
if (node.next) node.next.prev = node.prev;
else this.tail = node.prev;
node.prev = node.next = null;
this.size--;
}
toArray(): T[] {
const out: T[] = [];
for (let n = this.head; n; n = n.next) out.push(n.value);
return out;
}
}
ឥឡូវ test។ បង្កើត src/linked-list.test.ts៖
import { test } from "node:test";
import assert from "node:assert/strict";
import {
LinkedList,
ListNode,
DoublyLinkedList,
hasCycle,
findMiddle,
mergeSorted,
removeNthFromEnd,
} from "./linked-list.ts";
test("append, prepend and size", () => {
const list = new LinkedList<number>();
list.append(2).append(3).prepend(1);
assert.deepEqual(list.toArray(), [1, 2, 3]);
assert.equal(list.size, 3);
});
test("get, insertAt, indexOf", () => {
const list = LinkedList.from([10, 20, 40]);
list.insertAt(2, 30);
assert.deepEqual(list.toArray(), [10, 20, 30, 40]);
assert.equal(list.get(2), 30);
assert.equal(list.get(9), undefined);
assert.equal(list.indexOf(40), 3);
assert.equal(list.indexOf(99), -1);
assert.throws(() => list.insertAt(9, 1), RangeError);
});
test("removeFirst, removeLast, remove", () => {
const list = LinkedList.from([1, 2, 3, 4]);
assert.equal(list.removeFirst(), 1);
assert.equal(list.removeLast(), 4);
assert.equal(list.remove(2), true);
assert.equal(list.remove(99), false);
assert.deepEqual(list.toArray(), [3]);
assert.equal(list.tail?.value, 3);
list.removeLast();
assert.equal(list.head, null);
assert.equal(list.tail, null);
assert.equal(list.removeFirst(), undefined);
});
test("reverse", () => {
const list = LinkedList.from([1, 2, 3]);
list.reverse();
assert.deepEqual(list.toArray(), [3, 2, 1]);
assert.equal(list.tail?.value, 1);
list.append(0);
assert.deepEqual(list.toArray(), [3, 2, 1, 0]);
});
test("hasCycle", () => {
const list = LinkedList.from([1, 2, 3, 4]);
assert.equal(hasCycle(list.head), false);
list.tail!.next = list.head!.next;
assert.equal(hasCycle(list.head), true);
assert.equal(hasCycle(null), false);
});
test("findMiddle", () => {
assert.equal(findMiddle(LinkedList.from([1, 2, 3]).head)?.value, 2);
assert.equal(findMiddle(LinkedList.from([1, 2, 3, 4]).head)?.value, 3);
});
test("mergeSorted", () => {
const a = LinkedList.from([1, 4, 6]);
const b = LinkedList.from([2, 3, 7, 8]);
const merged = mergeSorted(a.head, b.head);
const out: number[] = [];
for (let n = merged; n; n = n.next) out.push(n.value);
assert.deepEqual(out, [1, 2, 3, 4, 6, 7, 8]);
});
test("removeNthFromEnd", () => {
const head = removeNthFromEnd(LinkedList.from([1, 2, 3, 4, 5]).head, 2);
const out: number[] = [];
for (let n = head; n; n = n.next) out.push(n.value);
assert.deepEqual(out, [1, 2, 3, 5]);
const single = removeNthFromEnd(new ListNode(1), 1);
assert.equal(single, null);
});
test("DoublyLinkedList removeNode", () => {
const list = new DoublyLinkedList<string>();
list.append("a");
const b = list.append("b");
list.append("c");
list.removeNode(b);
assert.deepEqual(list.toArray(), ["a", "c"]);
assert.equal(list.size, 2);
});
រត់ test៖
npx tsx --test
អ្នកគួរតែឃើញ test ទាំងអស់ជាប់។ បើមួយបរាជ័យ សូមអានសារ៖ វាបង្ហាញតម្លៃរំពឹង និងតម្លៃពិតប្រាកដ ដែលជាធម្មតាចង្អុលត្រង់ទៅ pointer ដែលអ្នកភ្លេចធ្វើបច្ចុប្បន្នភាព។
សាកល្បង list ក្នុង script តូចមួយ demo.ts៖
import { LinkedList, findMiddle } from "./src/linked-list";
const list = LinkedList.from([1, 2, 3, 4]);
list.prepend(0);
console.log(list.toArray().join(" -> "), "| size", list.size);
console.log(list.get(2), list.indexOf(4));
list.reverse();
console.log(list.toArray().join(" -> "));
list.reverse();
console.log(findMiddle(list.head)?.value);
npx tsx demo.ts
0 -> 1 -> 2 -> 3 -> 4 | size 5
2 4
4 -> 3 -> 2 -> 1 -> 0
2
ដើម្បីមានអារម្មណ៍ចំពោះភាពខុសគ្នានៃថ្លៃចំណាយ ដាក់ចូលជួរ 100,000 លេខក្នុង array ធម្មតា ហើយលុបវាទាំងអស់ដោយ shift() បន្ទាប់មកធ្វើដូចគ្នាជាមួយ append និង removeFirst() លើ list។ កំណែ array ត្រូវរុញធាតុដែលនៅសល់រាល់ការលុប (engine optimize រឿងនេះយ៉ាងខ្លាំង ដូច្នេះគម្លាតអាចតូចជាង Big-O ណែនាំ) ខណៈពេលដែល list ធ្វើការងារថេរដូចគ្នារាល់ពេល។ កំណត់ពេលទាំងពីរជាមួយ performance.now() ដូចអ្នកបានធ្វើក្នុងជំពូកទី 1 ហើយសាកល្បង 1,000,000 ធាតុផងដែរ។ ចាប់អារម្មណ៍ពីរបៀបដែលគម្លាតប្តូរនៅពេល input ធំឡើង។ ការលូតលាស់នោះគឺជាអ្វីដែល Big-O ពិពណ៌នា។
12. សាកល្បងដោយខ្លួនឯង
- ទំហំដោយគ្មាន counter។ សរសេរ function
length(head)ដែលរាប់ node ដោយដើរកាត់ list។ បន្ទាប់មកពន្យល់ថាហេតុអ្វីLinkedListរក្សាទុកcountជំនួសវិញ។ តើវាចំណាយប៉ុន្មានដើម្បីរក្សាវាឲ្យទាន់សម័យ? - បញ្ចូលតាមលំដាប់តម្រៀប។ បន្ថែម method
insertSorted(value)ទៅ list នៃលេខដែលស្ថិតក្នុងលំដាប់កើនរួចហើយ ដោយដាក់តម្លៃថ្មីនៅកន្លែងត្រឹមត្រូវ។ តើ Big-O ជាអ្វី? តើមានអ្វីកើតឡើងនៅពេលតម្លៃតូចជាងអ្វីៗទាំងអស់ ឬធំជាងអ្វីៗទាំងអស់? - List palindrome (សម្ភាសន៍)។ ដោយមាន singly linked list នៃខ្ទង់ ត្រឡប់
trueប្រសិនបើវាអានដូចគ្នាទាំងទៅមុខ និងថយក្រោយ ក្នុងពេល O(n) និង space បន្ថែម O(1)។ ព័ត៌មានជំនួយ៖ រកកណ្តាល បញ្ច្រាសពាក់កណ្តាលទីពីរ បន្ទាប់មកប្រៀបធៀបពាក់កណ្តាលទាំងពីរ។ តើដំណោះស្រាយរបស់អ្នកស្ដារ list ត្រឡប់វិញឬអត់? - លុបស្ទួន។ ដោយមាន list ដែលបានតម្រៀប
1, 1, 2, 3, 3, 3លុបស្ទួននៅនឹងកន្លែង ដើម្បីឲ្យតម្លៃនីមួយៗលេចឡើងម្តង។ Pass តែមួយ គ្មាន structure បន្ថែម។ បន្ទាប់មកដោះស្រាយកំណែដែលមិនបានតម្រៀបដោយប្រើSetហើយប្រៀបធៀបការដោះដូរ។ - រកចំណុចចាប់ផ្តើមនៃ cycle (សម្ភាសន៍)។ ពង្រីក
hasCycleដើម្បីឲ្យវាត្រឡប់ node ដែល cycle ចាប់ផ្តើម ឬnull។ ព័ត៌មានជំនួយ៖ បន្ទាប់ពីអណ្តើក និងទន្សាយជួបគ្នា ដាក់ pointer មួយត្រឡប់ទៅ head វិញ ហើយផ្លាស់ទាំងពីរម្តងមួយជំហាន។ ពួកវាជួបគ្នានៅចំណុចចាប់ផ្តើមនៃ cycle។ ព្យាយាមបញ្ចុះបញ្ចូលខ្លួនអ្នកថាហេតុអ្វី។ - LRU cache។ ដោយប្រើ
Map<string, DNode<...>>និងDoublyLinkedListបង្កើត cache ដែលមាន capacityn។get(key)ផ្លាស់ធាតុទៅខាងក្រោយ (ថ្មីបំផុត) ហើយត្រឡប់តម្លៃរបស់វា។put(key, value)បន្ថែមធាតុ ហើយប្រសិនបើ cache ពេញ លុបធាតុនៅខាងមុខ។ ប្រតិបត្តិការទាំងពីរត្រូវតែ O(1)។ អ្នកនឹងត្រូវរក្សាទុក key នៅក្នុង node នីមួយៗ និងវិធីផ្លាស់ node ទៅខាងក្រោយ (លុបវា បន្ទាប់មក append ឡើងវិញ)។
Linked list ដោះដូរការចូលប្រើលឿន ជាមួយការរៀបចំឡើងវិញលឿន ហើយជំនាញ pointer ពីជំពូកនេះ (រក្សាទុក next dummy node pointer ពីរល្បឿន) នឹងត្រឡប់មកវិញក្នុង tree និង graph។ ក្នុងជំពូកបន្ទាប់ Stack និង Queue យើងប្រើ array ធម្មតា និង LinkedList ថ្មីរបស់អ្នក ដើម្បីបង្កើត structure មានប្រយោជន៍បំផុតពីរក្នុងការសរសេរកម្មវិធី៖ មួយដែលមិនធ្វើវិញ (undo) និងមួយដែលរង់ចាំក្នុងជួរ។
ទិញកាហ្វេជូនខ្ញុំ
ចូលចិត្តមេរៀននេះមែនទេ? សូមពិចារណាទិញកាហ្វេជូនខ្ញុំមួយកែវ ដើម្បីគាំទ្រការបង្កើតមាតិកាឥតគិតថ្លៃបន្ថែមទៀត!