User Tools

Site Tools


Sidebar

1. บทนำ

2. การวิเคราะห์ Algorithm

3. List, Stack and Queue

4. Tree

5. Hashing

6. Priority Queues

7. การจัดเรียง (Sorting)

8. The Disjoint Set

9. Graph Algorithms

dsa:pqbiheap

6.3 Binary Heap

โครงสร้างข้อมูลที่เราใช้สำหรับ priority queue คือ binary heaps และเรียกสั้น ๆ ว่า heaps ซึ่งเป็นวิธีทั่วไปที่นิยมใช้ โครงสร้าง heaps มีคุณสมบัติสองอย่างเช่นเดียวกับ binary search trees คือ คุณสมบัติทางโครงสร้าง (structure property) และ คุณสมบัติทางลำดับค่า (heap order property) และก็เช่นเดียวกับ AVL trees กล่าวคือ การดำเนินการกับ heap อาจจะทำลายคุณสมบัติอย่างใดอย่างหนึ่งของมัน ดังนั้น เมื่อมีการดำเนินการที่ว่านี้แล้วจะต้องทำการปรับปรุง heap ให้มีคุณสมบัติกลับคืนสู่สภาพของมัน

6.3.1 คุณสมบัติทางโครงสร้าง (Structure Property)

heap เป็น binary tree ที่มีการเติมเต็มที่สมบูรณ์ ยกเว้นในระดับล่างสุดของทรี และการเติมเต็มแต่ละระดับนั้นจะต้องทำจากซ้ายไปขวาของทรี ทรีที่มีคุณลักษณะนี้ เรียกว่า complete binary tree ดังแสดงในรูปที่ 6.2

คุณสมบัติหนึ่งของ complete binary tree ที่มีความสูง (height) เป็น $h$ คือ ทรีนั้นจะมีจำนวนโนดอยู่ระหว่าง $2^h$ ถึง $2^{h+1} - 1$ โนด นั่นคือความสูงของ complete binary tree คือ $⌊log\ n⌋$ ซึ่งก็คือ $O(log\ n)$

เนื่องจาก complete binary tree มีรูปแบบของโครงสร้างที่สม่ำเสมอ ดังนั้นเราสามารถใช้อะเรย์แทนมันได้โดยไม่จำเป็นต้องใช้ตัวเชื่อมข้อมูล รูปที่ 6.3 เป็นอะเรย์ที่ใช้แทน heap ในรูปที่ 6.2.


รูปที่ 6.2 complete binary tree

A B C D E F G H I J
0 1 2 3 4 5 6 7 8 9 10 11 12 13

รูปที่ 6.3 อะเรย์ที่ใช้สำหรับ heap ในรูปที่ 6.2

สมาชิกของอะเรย์ที่ตำแหน่ง i ใดๆ (heap จะใช้ อะเรย์ตำแหน่งเริ่มต้นที่ 1) มีโนดลูกตัวซ้ายเป็นสมาชิกอยู่ที่เซลล์ตำแหน่ง 2i, และโนดลูกตัวขวาอยู่ที่เซลล์ถัดไป คือตำแหน่ง 2i + 1 และมีโนดแม่ (parent) เป็นสมาชิกของอะเรย์อยู่ที่เซลล์ตำแหน่ง ⌊i/2⌋ ดังนั้นการดำเนินการและการท่องไปในทรีจึงเป็นเรื่องง่ายและสามารถทำได้อย่างรวดเร็ว ปัญหาอย่างเดียวที่เหลือของการใช้วิธีนี้ คือ จะต้องประมาณการขนาดสูงสุดของ heap ล่วงหน้า แต่โดยทั่วไปก็ไม่เป็นปัญหาเนื่องจากสามารถปรับเปลี่ยนขนาดได้ตามความจำเป็น

ดังนั้นโครงสร้างข้อมูลของ heap จึงประกอบด้วยอะเรย์ของออปเจ็กต์ (ซึ่งเป็นออปเจ็กต์ Comparable) และเลขจำนวนเต็มแสดงจำนวนสมาชิกใน heap ณ เวลาหนึ่ง ๆ รูปที่ 6.4 แสดงโครงร่างคลาสของ priority queue รูปที่ 6.4a เป็นฟังก์ชัน constructors และ makeEmpty

ในบทนี้เราจะแสดงรูปของ heap ในรูปแบบของทรี โดยที่การใช้งานจริงคืออะเรย์ที่กล่าวมาแล้ว

public class BinaryHeap 
{   public BinaryHeap( ) { /* Figure 6.4a */ }
    public BinaryHeap( int capacity ) { /* Figure 6.4a */ }
    public void insert( Comparable x ) throws Overflow { /* Figure 6.8 */ }
    public Comparable findMin( )
        {   if( isEmpty( ) )
                return null;
             return array[ 1 ];
        }
    public Comparable deleteMin( ){ /* Figure 6.12 */ }
    public boolean isEmpty( ) { return currentSize == 0; } 
    public boolean isFull( )  { return currentSize == array.length - 1;}
    public void makeEmpty( ) { /* Figure 6.4a */ }
 
    private static final int DEFAULT_CAPACITY = 100;
    private int currentSize;    	     // Number of elements in heap
    private Comparable [ ] array; 	     // The heap array
    private void percolateDown( int hole ) { /* Figure 6.12 */ }
    private void buildHeap( )  { /* Figure 6.14 */ }
}

รูปที่ 6.4 โครงร่างคลาสของ priority queue

public BinaryHeap( )
{    this( DEFAULT_CAPACITY );  }
public BinaryHeap( int capacity )
{     currentSize = 0;
      array = new Comparable[ capacity + 1 ];
}
 
public void makeEmpty( )
{      currentSize = 0;    } 

รูปที่ 6.4a Constructor และ makeEmpty

6.3.2. คุณสมบัติทางลำดับค่า (heap order property)

คุณสมบัติที่ทำให้การดำเนินการต่าง ๆ ของ heap ทำงานอย่างรวดเร็วได้ก็คือคุณสมบัติทางลำดับค่า (heap order property) เนื่องจากเราต้องการค้นหาค่าที่น้อยที่สุดในโครงสร้างให้ได้อย่างรวดเร็วที่สุดดังนั้นจึงสมเหตุผลที่เราจะเอาสมาชิกที่มีค่าน้อยที่สุดมาไว้ตำแหน่งของโนดราก ถ้าเราถือว่าทุก ๆ ทรีย่อยต้องเป็น heap ด้วยก็หมายความว่าโนดใด ๆ ในทรีจะต้องมีค่าน้อยกว่าโนดด้านล่างของมัน

ตามที่กล่าวข้างบนนี้ก็จะได้คุณสมบัติทางลำดับค่าว่า ทุก ๆ โนด X ค่าของ key ในโนดแม่ของโนด X ต้องมีค่าน้อยกว่า (หรือ เท่ากับ) ค่า key ในโนด X ยกเว้นโนดรากซึ่งไม่มีโนดแม่ รูปที่ 6.5 มีทรีทางด้านซ้ายเป็น heap แต่ทรีทางด้านขวาไม่เป็น (เส้นประในรูปแสดงจุดที่ทำให้มันไม่เป็น heap เนื่องจากไม่เป็นไปตามข้อกำหนดลำดับค่า) ด้วยคุณสมบัติลำดับค่านี้ ทำให้ค่าที่น้อยที่สุดอยู่ในตำแหน่งรากเสมอ ดังนั้น เราจึงดำเนินการ find_min ด้วยเวลาคงที่


รูปที่ 6.5 ทรีทางด้านซ้ายเป็น heap แต่ทรีทางด้านขวาไม่เป็น

6.3.3 การดำเนินการพื้นฐานใน Heap

การดำเนินการพื้นฐานทั้ง 2 ของ heap จะต้องประกอบด้วยการทำงานบางอย่างที่มันยังคงสถานะความเป็น heap เอาไว้ได้

การบรรจุค่าลงใน heap (Insert, enqueue)

เพื่อบรรจุสมาชิก X เข้าใน heap เราจะสร้างช่องว่าง (hole) ในตำแหน่งว่างที่อยู่ถัดไปจากโนดสุดท้ายของ heap ถ้าสามารถบรรจุ X ลงในช่องว่างนี้ได้โดยไม่ทำให้คุณสมบัติลำดับค่าเสียไป ก็ให้ทำได้เลยและเป็นอันเสร็จสิ้นการทำงาน ไม่เช่นนั้นเราก็จะต้องทำการเลื่อนสมาชิกที่อยู่ในโนดแม่ของช่องว่างลงมาในช่องว่าง ซึ่งก็เป็นการเลื่อนช่องว่างขึ้นไปหาราก และจะทำกระบวนการนี้ซ้ำจนกว่า X จะอยู่ในช่องว่างที่ถูกต้องได้ รูปที่ 6.6 แสดงการบรรจุค่า 14 ลงในทรี เราสร้างช่องว่างให้กับตำแหน่งถัดไปของ heap ถ้าบรรจุ 14 ลงในช่องนี้ก็จะทำให้เสียคุณสมบัติทางลำดับค่าของ heap ดังนั้นจึงเลื่อน 31 ลงในช่องว่างนี้ และเลื่อนช่องว่างขึ้นแทน และทำเช่นนี้ต่อเนื่องขึ้นไปดังรูปที่ 6.7 จนพบตำแหน่งโนดที่ถูกต้อง

วิธีการดังที่กล่าวมานี้เรียกว่า percolate up กล่าวคือสมาชิกตัวใหม่จะไหลขึ้นข้างบนของ heap จนกว่าจะพบกับตำแหน่งที่ถูกต้อง และถ้าสมาชิกตัวใหม่ที่จะบรรจุนั้นเป็นค่าที่น้อยที่สุด มันก็จะถูกเลื่อนให้ขึ้นไปอยู่ที่ตำแหน่งบนสุด รูปที่ 6.8 แสดงฟังก์ชันการบรรจุค่าด้วยกรรมวิธีที่กล่าว เวลาที่ใช้ในการ insert อาจเป็นเวลาที่มากที่สุด คือ O(log N) ได้ ถ้าค่าที่บรรจุนั้นเป็นค่าต่ำสุดและมีการเลื่อนขึ้นจนไปถึงราก อย่างไรก็ตามโดยเฉลี่ยแล้วการเลื่อนขึ้นจะหยุดลงก่อนจะถึงตำแหน่งราก


รูปที่ 6.6 การบรรจุค่า 14 ลงในทรี สร้างช่องว่างให้กับตำแหน่งถัดไปของ heap และเลื่อนช่องว่างขึ้น


รูปที่ 6.7 ขั้นตอนที่เหลือของการบรรจุค่า 14 จากรูปที่ 6.6

/* @param x the item to insert. */
public void insert( Comparable x ) throws Overflow
{   if ( isFull( ) )
        throw new Overflow( );
    // Percolate up
    int hole = ++currentSize;
    for( ; hole > 1 && x.compareTo( array[ hole / 2 ] ) < 0; hole /= 2 )
        array[ hole ] = array[ hole / 2 ];
    array[ hole ] = x;
} 

รูปที่ 6.8 ฟังก์ชันการบรรจุค่าลงใน heap

ฟังก์ชันการลบ deleteMin

การจัดการกับการทำงานของฟังก์ชัน deleteMin นั้นคล้ายกันกับการบรรจุค่า การค้นหาค่าที่น้อยที่สุดนั้นเป็นเรื่องง่าย (อยู่ที่โนดราก) ส่วนที่ยุ่งยากคือการลบมันออกไป เมื่อมีการลบค่าที่น้อยที่สุดออกไปก็จะเกิดช่องว่างขึ้นที่รากและจำนวนโนดที่เหลืออยู่ใน heap ก็จะมีจำนวนน้อยลงหนึ่งตัว ดังนั้นสมาชิกตัวสุดท้ายคือ X ใน heap ต้องถูกย้ายที่ไปยังตำแหน่งโนดอื่นใน heap

ถ้า X สามารถใส่ลงในช่องว่างใหม่ที่เกิดขึ้นได้ก็ให้ทำและจบการทำงาน แต่ถ้าไม่ได้เราจะต้องเลื่อนโนดลูกของช่องว่างตัวที่มีค่าน้อยกว่าระหว่างโนดลูกซ้ายและโนดลูกขวาเข้าในช่องว่างซึ่งเป็นการผลักให้ช่องว่างเลื่อนลงข้างล่างหนึ่งระดับ เราจะทำเช่นนี้ไปเรื่อย ๆ จนกว่าจะสามารถใส่ X ลงในช่องว่างได้ นั่นคือการหาตำแหน่งที่ถูกต้องที่จะใส่ X ตามเส้นทางจากโนดรากลงไปนั่นเอง รูปที่ 6.9 ถึงรูปที่ 6.11 แสดงขั้นตอนการทำงาน deleteMin วิธีการทำงานของฟังก์ชันที่ได้กล่าวมานี้เรียกว่า percolate down

ความผิดพลาดที่อาจจะเกิดขึ้นในการใช้งาน heaps คือ เมื่อจำนวนสมาชิกใน heap เป็นเลขคู่ และมีโนดหนึ่งที่มีโนดลูกเพียงโนดเดียว ดังนั้นจึงต้องใช้การทดสอบพิเศษเพิ่มเติมขึ้นมา ในโปรแกรมที่แสดงในรูปที่ 6.12 นั้นเราใช้การทดสอบที่ว่านี้ที่บรรทัดที่ 5 worst-case running time ของการทำงานของฟังก์ชันนี้คือ $O(log\ N)$ โดยเฉลี่ยแล้วสมาชิกที่ถูกใส่ลงที่รากจะถูกเลื่อนลงไปเกือบจะถึงล่างสุดของ heap ดังนั้น average running time จึงเป็น $O(log\ N)$


รูปที่ 6.9 สร้างช่องว่างที่โนดราก


รูปที่ 6.10 สองขั้นตอนการทำงานต่อมาจากรูปที่ 6.9


รูปที่ 6.11 สองขั้นตอนสุดท้ายการทำงานต่อมาจากรูปที่ 6.10

6.3.4. การดำเนินการอื่น ๆ ใน Heap

จากที่กล่าวมาจะเห็นว่า ถึงแม้การค้นหาค่าที่น้อยที่สุดสามารถทำได้ด้วยเวลาคงที่ heap ที่ได้รับการออกแบบสำหรับการค้นหาสมาชิกที่มีค่าน้อยที่สุด (บางที่เรียกว่า Min heap) ก็ไม่ให้ประโยชน์ใด ๆ ถ้าต้องการหาสมาชิกที่มีค่ามากที่สุด ความจริงแล้วเราต้องใช้เวลาเป็น linear time ในการค้นหาค่าใด ๆ ใน heap ทั้งนี้เนื่องจาก heap ไม่มีสารสนเทศการเรียงลำดับค่าอยู่เลยจึงต้องใช้วิธีการ scan ไปทุก ๆ ค่า พิจารณาโครงสร้าง heap ขนาดใหญ่ในรูปที่ 6.13 จะพบว่า ถึงแม้ว่าเราจะรู้ว่าสมาชิกตัวที่มีค่ามากที่สุดอยู่ที่ leaf ตัวใดตัวหนึ่ง ก็ไม่ได้ได้ช่วยให้การทำงานเร็วขึ้น ทั้งนี้เพราะเหตุว่า ครึ่งหนึ่งของจำนวนสมาชิกทั้งหมดยังอยู่ระดับ leaf กรณีที่มีความจำเป็นและสำคัญมากที่จะต้องรู้ตำแหน่งของสมาชิกใน heap ด้วย ก็จำเป็นที่จะต้องใช้โครงสร้างอื่น ๆ เข้าช่วย เช่น ใช้ตารางแฮชร่วมด้วย (อย่าลืมว่าโมเดลของ heap ไม่อนุญาตให้มองเห็นภายใน heap)

public Comparable deleteMin( )
{   if( isEmpty( ) )
        return null;
    Comparable minItem = findMin( );     // return array[1]
    array[ 1 ] = array[ currentSize-- ];
    percolateDown( 1 );
    return minItem;
}
 
private void percolateDown( int hole )
{
/* 1*/  int child;
/* 2*/  Comparable tmp = array[ hole ];
/* 3*/  for( ; hole * 2 <= currentSize; hole = child )
        {
/* 4*/     child = hole * 2;
/* 5*/     if( child != currentSize &&
/* 6*/         array[ child + 1 ].compareTo( array[ child ] ) < 0 )
/* 7*/         child++;
/* 8*/     if( array[ child ].compareTo( tmp ) < 0 )
/* 9*/          array[ hole ] = array[ child ];
           else
/*10*/          break;
        }
/*11*/  array[ hole ] = tmp;
} 

รูปที่ 6.12 ฟังก์ชัน deleteMin ใน binary heap


รูปที่ 6.13 โครงสร้าง heap ขนาดใหญ่

สมมุติถ้าเรารู้ตำแหน่งของสมาชิกทุกตัว (ด้วยวิธีใดก็ตาม) ก็จะทำให้การดำเนินการอื่น ๆ ทำได้โดยไม่เสียเวลามาก การดำเนินการ 3 อย่างแรกข้างล่างนี้มี worst-case time เป็น logarithmic

decreaseKey
decreaseKey(p, D) ลดค่าของข้อมูลที่ตำแหน่ง p ลงเป็นจำนวน D เนื่องจากการทำงานนี้อาจจะทำให้เสียคุณสมบัติความเป็น heap ดังนั้นจึงต้องจัดการด้วยการทำ percolate up

increaseKey
increaseKey(p, D) เพิ่มค่าของข้อมูลที่ตำแหน่ง p ขึ้นเป็นจำนวน D. เนื่องจากการทำงานนี้อาจจะทำให้เสียคุณสมบัติความเป็น heap ดังนั้นจึงต้องจัดการด้วยการทำ percolate down

delete
delete(p) ลบโนดที่ตำแหน่ง p ออกจาก heap ซึ่งทำได้โดยการทำ decreaseKey(p, $-\infty$) ก่อนจากนั้นตามด้วยการทำ deleteMin()

buildHeap
การดำเนินการ buildHeap ที่มีสมาชิกอินพุต N ตัว เป็นการนำสมาชิก N ตัวมาลงใน heap ว่าง ซึ่งทำโดยการบรรจุค่าเหล่านั้นลงตามลำดับที่อ่านเข้ามา เนื่องจากการบรรจุแต่ละค่าจะใช้เวลาเป็น $O(1)$ โดยเฉลี่ยและใช้เวลาเป็น $O(log\ N)$ สำหรับ worst-case time ดังนั้น running time ทั้งหมดของอัลกอริทึมจึงเป็น $O(N)$ โดยเฉลี่ย แต่เป็น $O(N\ log\ N)$ สำหรับ worst-case เนื่องจากการทำงานนี้จะไม่มีการทำงานอื่น ๆ มาแทรกและเรารู้ว่าเวลาเฉลี่ยที่ใช้ในการทำงานเป็น linear time ดังนั้นเราจึงประกันได้ว่าเวลาการทำงานของมีขอบเขตเป็น linear time

อัลกอริทึมที่ใช้ทั่วไป คือ ใส่ข้อมูล N ตัวลงในทรีโดยไม่ต้องสนใจการเรียงลำดับค่า เพียงแต่รักษาโครงสร้างเท่านั้น จากนั้น ถ้าให้ฟังก์ชัน percolateDown(i) เป็นการเลื่อนลงของโนด i แล้วอัลกอริทึมในรูปที่ 6.14 เป็น pseudocode ที่ใช้ในการสร้างการลำดับค่าตาม heap-order tree

private void buildHeap()
{   for ( int i = currentSize / 2; i > 0; i-- )
	    percolateDown( i );
}

รูปที่ 6.14 เค้าโครงของ buildHeap

รูปทรีด้านซ้ายของรูปที่ 6.15 เป็นทรีที่ไม่ได้มีการจัดเรียกลำดับใด ๆ แต่เกิดจากการบรรจุค่าเรียงตามลำดับที่อ่านค่าเข้ามาเท่านั้น (unordered tree) รูปทรีในรูปที่ 6.15 ถึงรูปที่ 6.18 แสดงผลของการ percolate downs จำนวน 7 รอบ เส้นประของเส้นกิ่งในรูปแสดงให้เห็นถึงการเปรียบเทียบค่า 2 ครั้ง โดยการเปรียบเทียบครั้งแรกเป็นการหาค่าที่น้อยกว่าของโนดลูกอีกครั้งเปรียบเทียบค่าของโนดลูกตัวที่มีค่าน้อยกว่ากับค่าของโนดนั้น ๆ เอง และจากรูปมีจำนวนเส้นประเพียง 10 เส้นเท่านั้น ดังนั้นหมายความว่าจะมีการเทียบค่ากันจำนวน 20 ครั้ง


รูปที่ 6.15 ภาพด้านซ้ายเป็นทรีเริ่มต้น ภาพด้านขวาหลัง percolateDown(7)


รูปที่ 6.16 ภาพด้านซ้ายหลัง percolateDown(6) ภาพด้านขวาหลัง percolateDown(5)


รูปที่ 6.17 ด้านซ้ายหลัง percolateDown(4) ภาพด้านขวาหลัง percolateDown(3)


รูปที่ 6.18 ด้านซ้ายหลัง percolateDown(2) ภาพด้านขวาหลัง percolateDown(1)

เพื่อกำหนดขอบเขตเวลาของ running time ของ buildHeap เราจะต้องกำหนดจำนวนเส้นประ เราสามารถทำได้โดยการคำนวณค่าผลรวมของความสูง (sum of the heights) ของโนดทั้งหมดใน heap ซึ่งก็คือจำนวนสูงสุดของเส้นประนั่นเอง และผลบวกทั้งหมดนี้ คือ $O(N)$

ทฤษฎี 6.1 สำหรับ perfect binary tree ที่มีความสูง $h$ ซึ่งมีจำนวนโนดทั้งสิ้น $2^{h+1}–1$ โนด มีผลบวกของความสูงของโนดทั้งหมดเป็น $2^{h+1}–1–(h+1)$

พิสูจน์ จะเห็นได้ว่าทรีจะมีโนดหนึ่งโนดอยู่ที่ระดับความสูง $h$, มี 2 โนดที่ระดับความสูง $= h – 1$, มีโนดจำนวน $2^2$ โนดที่ระดับความสูง $= h – 2$, จึงกล่าวได้ว่าทรีนี้มีโนดจำนวน $2^i$ โนดที่ระดับความสูง $= h – i$ ดังนั้นผลบวกของความสูงของโนดทั้งหมด คือ \begin{align*} S & =∑_{i=0}^h 2^i(h-i) \\ & = h+2(h-1)+4(h-2)+8(h-3)+16(h-4)+⋯+2^{h-1} (1) \ \ \text{(6.1)} \\ \text{คูณด้วย 2 จะได้ } \\ 2S &= 2h+4(h-1)+8(h-2)+16(h-3)+32(h-4)+⋯+2^h (1) \ \ \text{(6.2)} \\ \end{align*} สมการทั้งสองลบกันจะได้สมการที่ 6.3 จะเห็นว่าเทอมเกือบทั้งหมดหักลบกัน เช่น เรามี $2h – 2(h – 1) = 2, 4(h – 1) – 4(h – 2) = 4$ และ ฯลฯ เทอมสุดท้ายของสมการ 6.2 คือ 2h ไม่ปรากฏอยู่ในสมการ 6.1 ดังนั้นมันจึงยังคงเหลืออยู่ในสมการ 6.3 เทอมแรกในสมการ 6.1 คือ h ก็ไม่อยู่ในสมการ 6.2 ดังนั้นจึงมีค่า –h ปรากฏอยู่ในสมการ 6.3 ดังนั้นจะได้ $$S=-h+2+4+8+⋯+2^{h-1}+2^h=(2^{h+1}-1) - (h+1) \ \ \text{(6.3)}$$ ซึ่งเป็นการพิสูจน์ทฤษฎี

ไบนารีทรีที่เป็น complete tree ไม่ได้เป็น perfect tree แต่ผลลัพธ์ที่ได้ข้างบนนี้ก็เป็นขอบเขตบนของผลบวกของความสูงของโนดใน complete tree เนื่องจาก complete tree มีจำนวนโนดอยู่ระหว่าง $2^h$ ถึง $2^{h+1}$ โนด ดังนั้นโดยทฤษฎีนี้จึงถือได้ว่าผลบวกนี้มีค่าเป็น $O(N)$ เมื่อ $N$ คือจำนวนโนด

dsa/pqbiheap.txt · Last modified: 2021/09/13 09:33 by wasu

Page Tools