4.3 The Search Tree ADT - Binary Search Trees

การประยุกต์ใช้ binary trees ที่สำคัญอย่างหนึ่ง คือ การใช้ในการค้นหาค่า สมมุติให้แต่ละโนดใน tree เก็บค่าไว้หนึ่งหน่วยข้อมูล เป็นเลขจำนวนเต็มที่มีค่าไม่ซ้ำกัน

คุณสมบัติของ binary search tree คือ binary search tree ยังคงเป็น binary tree ซึ่งทุก ๆ โนด X ใด ๆ ในทรีต้องมีค่าของ keys ทั้งหมดทางทรีย่อยซ้าย (left subtree) ของมันต้องน้อยกว่าค่าของ key คือ X, และค่าทั้งหมดของ keys ทางด้านทรีย่อยขวา (right subtree) ต้องมากกว่าค่าของ key คือ X จะเห็นได้ว่าด้วยคุณสมบัติดังกล่ามมานี้ทำให้สมาชิกในทรีมีการจัดลำดับในโครงสร้างทรีขึ้น รูปที่ 4.15 แสดง binary tree ที่มีทรีรูปด้านซ้ายเป็น binary search tree แต่รูปด้านขวาไม่เป็นเนื่องจากโนดที่มีค่า 7 ไปอยู่ตำแหน่งที่เป็นทรีย่อยซ้ายของโนดที่มีค่า 6 (ซึ่งก็คือโนดราก)


รูปที่ 4.15 ไบนารีทรีที่รูปทางซ้ายมือเป็น binary search tree

เนื่องจากว่าเรากำหนดนิยามทรีในแบบ recursive จึงมักจะเขียนฟังก์ชันการทำงานของทรีเป็นแบบ recursive ด้วย เนื่องจากว่าความลึกเฉลี่ยของ binary search tree เป็น $O(log\ N)$ ดังนั้นจึงไม่ต้องกังวลกับเรื่องที่จะเกิด stack overflow

ข้อมูลทั้งหมดในโครงสร้างของ binary search tree จะต้องสามารถจัดเรียงลำดับได้ ดังนั้นจะต้องเขียน generic class ที่ต้องประกอบด้วย interface ที่มีคุณสมบัติที่สามารถทำสิ่งนี้ได้ และในที่นี้คือ interface ชื่อ Comparable โดยจะเปรียบเทียบข้อมูล 2 หน่วยข้อมูลในทรีด้วยฟังก์ชัน CompareTo โดยจะให้ค่าเป็นลบหรือค่า 0 หรือค่าบวกขึ้นอยู่กับว่าค่าของออปเจ็กต์ Comparable นั้นจะน้อยกว่าหรือเท่ากับหรือมากกว่าค่าของออปเจ็กต์ที่เป็นพารามิเตอร์ของฟังก์ชันถ้าข้อมูลมีค่าเท่ากัน รูปที่ 4.16 แสดงคลาสของโนดใน binary search tree

รูปที่ 4.17 แสดงโครงร่างของคลาส BinarySearchTree มี data member ตัวเดียวที่ใช้สำหรับอ้างถึงโนดรากของทรีเท่านั้นและจะมีค่า (หรืออ้างถึง) ค่า null กรณีที่ทรีเป็นทรีว่าง วิธีที่ใช้ทั่วไปคือจะใช้ public method ในการเรียกใช้งาน private method ที่เป็นแบบ recursive อีกชั้นหนึ่ง private method ชื่อ elementAt ให้ค่ากลับเป็น (ตัวอ้างอิงถึง) หน่วยข้อมูลที่เก็บอยู่ในโนดที่อ้างอิงโดย t หรือก็จะเป็น null ถ้า t เป็น null

class BinaryNode 
{
  	// Constructors
    BinaryNode( Comparable theElement )
    {	this( theElement, null, null );	}
    BinaryNode( Comparable theElement, BinaryNode lt, BinaryNode rt )
    {   element = theElement; left = lt; right = rt;}
 
    // Friendly data; accessible by other package routines
 
    Comparable element;     	// The data in the node
    BinaryNode left;         	// Left child
    BinaryNode right;        	// Right child
}

รูปที่ 4.16 คลาส BinaryNode

public class BinarySearchTree 
 {
    public BinarySearchTree( )   { root = null; }
    public void insert( Comparable x )   { root = insert( x, root ); }
    public void remove( Comparable x ) { root = remove( x, root ); }
    public Comparable findMin( )  { return elementAt( findMin( root ) );}
    public Comparable findMax( ) { return elementAt( findMax( root ) );}
    public Comparable find( Comparable x )  { return elementAt( find( x, root ) );}
    public void makeEmpty( )  { root = null; }
    public boolean isEmpty( )  { return root == null; }
    public void printTree( )  {/* Figure 4.56 */}
 
    private BinaryNode root;  	// The tree root 
 
    private Comparable elementAt( BinaryNode t )  	{ return t == null ? null : t.element; }
    private BinaryNode find( Comparable x, BinaryNode t ) 		{/* Figure 4.18 */}
    private BinaryNode findMin( BinaryNode t )  {/* Figure 4.19 */}
    private BinaryNode findMax( BinaryNode t ) {/* Figure 4.20 */}
    private BinaryNode insert( Comparable x, BinaryNode t )  {/* Figure 4.22 */ }
    private BinaryNode remove( Comparable x, BinaryNode t )  {/* Figure 4.25 */ }
    private void printTree( BinaryNode t )  {/* Figure 4.56 */}
}

รูปที่ 4.17 โครงร่างคลาส BinarySearchTree

4.3.1 Find

การทำงานของ find() ให้ค่ากลับเป็น reference ที่อ้างถึงโนดในทรี T ที่มีหน่วยข้อมูล X, หรือ null ถ้าไม่มีโนดดังกล่าว ถ้า T เป็นทรีว่างก็ให้ค่ากลับเป็น null ถ้าหน่วยข้อมูลที่อยู่ที่ T คือ X ก็ให้ค่ากลับเป็น T ถ้าไม่เป็นดังที่กล่าวมานี้ ก็ให้เรียกใช้ method นี้ในแบบ recursive ที่กระทำต่อทรีย่อยของ T, อาจจะเป็นทรีย่อยซ้ายหรือขวาขึ้นอยู่กับค่า X เมื่อเทียบกับค่าที่อยู่ใน T รูปที่ 4.18 เป็นคำสั่งที่ใช้ตามวิธีที่กล่าวมาข้างบนนี้

จะเห็นว่าลำดับของการตรวจสอบในโปรแกรมนี้มีความสำคัญกล่าวคือจะต้องทดสอบความเป็นทรีว่างเสียก่อนมิฉะนั้นแล้วก็จะเกิดภาวะที่จะเข้าถึงหน่วยข้อมูลที่อ้างอิงที่ null ซึ่งจะเกิด exception (ในกรณีนี้คือ NullPointerEception) อีกประการหนึ่งคือ recursive ที่ใช้ในโปรแกรมนี้เป็นแบบที่เรียกว่า tail recursions ซึ่งความจริงแล้วสามารถใช้คำสั่งลูปของ while แทนได้

/** * Internal method to find an item in a subtree.
    * x is item to search for. t the node that roots the tree.
    * @return node containing the matched item.      */
 
private BinaryNode find( Comparable x, BinaryNode t )
{
    if( t == null )   
        return null;
    if( x.compareTo( t.element ) < 0 )
	    return find( x, t.left );
    else if( x.compareTo( t.element ) > 0 )
        return find( x, t.right );
    else
        return t;    // Match
} 

รูปที่ 4.18 Find operation

4.3.2 findMin และ findMax

ฟังก์ชันนี้ให้ค่ากลับเป็น reference ที่อ้างถึงโนดที่บรรจุค่าที่น้อยที่สุดและมากที่สุดตามลำดับ ฟังก์ชัน findMin เริ่มต้นทำงานที่ตำแหน่งของโนดรากของทรีแล้ว traverse ลงไปด้านซ้ายเรื่อย ๆ ตราบเท่าที่ยังมี left child และจะสิ้นสุดลงที่ค่าที่น้อยที่สุด ส่วนฟังก์ชัน findMax ก็จะทำงานในลักษณะเดียวกันกับฟังก์ชัน findMin เพียงแต่จะทำการท่องลงไปด้าน right child เท่านั้น โปรแกรมของฟังก์ชัน findMin เขียนแบบ recursive และของฟังก์ชัน findMax ไม่เป็นแบบ recursive ดังรูปที่ 4.19 และรูปที่ 4.20 ตามลำดับ

/* Internal method to find the smallest item in a subtree.
 * @param t the node that roots the tree.
 * @return node containing the smallest item. */
 
private BinaryNode findMin( BinaryNode t )
{
    if ( t == null )
        return null;
    else if ( t.left == null )
        return t;
    return findMin ( t.left );
} 

รูปที่ 4.19 ฟังก์ชัน findMin

/* Internal method to find the largest item in a subtree.
 * @param t the node that roots the tree.
 * @return node containing the largest item. */
 
private BinaryNode findMax( BinaryNode t )
{
    if( t != null )
        while( t.right != null )
            t = t.right;
        return t;
}

รูปที่ 4.20 ฟังก์ชัน findMax

4.3.3 Insert

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

กรณีที่ต้องการจัดการกับโนดที่มีค่าซ้ำก็สามารถทำได้ด้วยการเพิ่มเติมฟิลด์พิเศษลงในโนดของทรีเพื่อใช้ในการบันทึกจำนวนตัวของค่าของโนดนั้น ๆ ซึ่งทำให้จะต้องใช้เนื้อที่สำหรับทรีเพิ่มขึ้น อย่างไรก็ตามวิธีที่กล่าวนี้ไม่สามารถใช้ได้ถ้าหากว่าค่า key นั้นเป็นส่วนประกอบย่อยของออปเจ็กต์ที่มีหลายฟิลด์ และในกรณีเช่นนี้เราก็สามารถจัดการได้ด้วยการจัดเก็บทั้งโครงสร้างของออปเจ็กต์ในโครงสร้างข้อมูลเพิ่มเติมเช่นลิสต์หรือ search tree อีกโครงสร้างหนึ่ง


รูปที่ 4.21 ก่อนและหลังการบรรจุ 5 ลงใน binary search tree

รูปที่ 4.22 แสดงโปรแกรมฟังก์ชันการบรรจุ เนื่องจาก t นั้นอ้างอินอยู่ที่ตำแหน่งโนดรากของทรีและรากจะเปลี่ยนไปเมื่อมีการบรรจุครั้งแรก ดังนั้นจึงเขียนโปรแกรมฟังก์ชัน insert ที่ให้ค่ากลับเป็นการอ้างอิงที่รากของทรีใหม่ บรรทัดที่ 4 และ 6 เป็นการบรรจุและเชื่อมต่อ x เข้าในทรีในแบบ recursive

/** x the item to insert.  t the node that roots the tree.
 * @return the new root.  */
        private BinaryNode insert( Comparable x, BinaryNode t )
        {
/* 1*/      if( t == null )
/* 2*/          t = new BinaryNode( x, null, null );
/* 3*/      else if( x.compareTo( t.element ) < 0 )
/* 4*/          t.left = insert( x, t.left );
/* 5*/      else if( x.compareTo( t.element ) > 0 )
/* 6*/          t.right = insert( x, t.right );
/* 7*/      else
/* 8*/          ;  // Duplicate; do nothing
/* 9*/      return t;
        }

รูปที่ 4.22 ฟังก์ชัน insert สำหรับบรรจุค่าลงใน binary search tree

4.3.4 remove

เป็นเรื่องปกติของโครงสร้างข้อมูลแบบต่าง ๆ ที่การทำงานการลบ (remove หรือ delete) เมื่อค้นหาโนดที่ต้องการลบแล้วจะต้องพิจารณาสิ่งที่อาจจะมีประเด็นที่ต้องพิจารณาหลายอย่างด้วยกัน ดังนี้

ถ้าโนดนั้นเป็น leaf ก็ลบออกได้ทันที ถ้าโนดนั้นมีโนดลูก 1 โนด ก็ทำการปรับเส้นเชื่อม (pointer) จากโนดแม่ของมันให้ข้ามโนดที่จะลบนั้นแล้วจึงทำการลบดังแสดงในรูปที่ 4.23

กรณีที่ซับซ้อนขึ้นในการลบโนดคือกรณีที่ถ้าโนดที่จะลบนั้นมีโนดลูก 2 โนด วิธีการทั่วไป คือ ให้แทนที่ค่าของโนดที่ลบนั้นด้วยค่าที่น้อยที่สุดของ right subtree (ซึ่งสามารถค้นหาพบได้โดยง่าย) และให้ทำการ delete โนดนั้นในแบบ recursive เนื่องจากโนดที่มีค่าน้อยที่สุดใน right subtree ย่อมไม่มี left child ดังนั้น การ remove ครั้งหลังจึงทำได้ง่าย

รูปที่ 4.24 แสดงทรีเริ่มแรกและผลที่ได้จากการลบ โนดที่ต้องการจะลบคือลูกทางซ้ายของโนดรากซึ่งมีค่า 2 และจะถูกแทนที่ด้วยค่าที่น้อยที่สุดในทรีย่อยขวาของมันซึ่งก็คือ 3 จากนั้นก็จะทำการลบโนด 3 ด้วยวิธีเดียวกับที่กล่าวมาแล้วข้างบน รูปที่ 4.25 แสดงฟังก์ชันการลบโนด โดยจะมีการทำงาน 2 รอบลงข้างล่างของทรี คือ การค้นหาค่าโนดที่ต้องการและตามด้วยการทำงานการลบโนดที่มีค่าน้อยที่สุดของทรีย่อยขวาของมัน


รูปที่ 4.23 ก่อนและหลัง การลบโนด (4) ซึ่งมีโนดลูก 1 โนด


รูปที่ 4.24 ก่อนและหลังการลบโนด (2) ที่มีโนดลูก 2 โนด

private BinaryNode remove( Comparable x, BinaryNode t )
        {
            if( t == null )
                return t;   // Item not found; do nothing
            if( x.compareTo( t.element ) < 0 )
                t.left = remove( x, t.left );
            else if( x.compareTo( t.element ) > 0 )
                t.right = remove( x, t.right );
            else if( t.left != null && t.right != null ) // Two children
            {
                t.element = findMin( t.right ).element;
                t.right = remove( t.element, t.right );
            }
            else
                t = ( t.left != null ) ? t.left : t.right;
            return t;
        }

รูปที่ 4.25 Deletion routine

ในกรณีที่คาดว่าจะมีการใช้การทำงานการลบในจำนวนน้อย ๆ ครั้ง ก็มักจะใช้การลบด้วยวิธีที่เรียกว่า lazy deletion กล่าวคือโนดที่จะถูกลบจะไม่มีการนำมันออกจากทรี แต่ยังคงโนดนั้นไว้ที่ตำแหน่งเดิมเพียงแต่ระบุสารสนเทศเพิ่มเติมลงในโนดนั้นว่าโนดมันเป็นโนดที่ได้รับการลบออกแล้ว วิธีการนี้นิยมใช้ในกรณีที่ทรีมีข้อมูลซ้ำเนื่องจากเพียงแต่เพิ่มฟิลด์สำหรับการนับจำนวนค่าซ้ำและเมื่อมีการลบก็เพียงลดจำนวนนับลงเท่านั้น

4.3.5 การวิเคราะห์ Average-Case

เราคาดหวังว่าการทำงานทั้งหมดที่ได้กล่าวถึงมาแล้วในหัวข้อก่อนหน้านี้จะใช้เวลาเป็น $O(log\ N)$ เนื่องจากเราใช้เวลาคงที่ในการเคลื่อนที่ลงในทรีแต่ละระดับ ความจริงแล้ว running time ของการทำงานที่กล่าวมาแล้วนั้น คือ $O(d)$, เมื่อ $d$ คือ depth ของโนดที่มีข้อมูลที่จะประมวลผล

ในหัวข้อนี้จะได้แสดงให้เห็นว่าค่าความลึกเฉลี่ย (average depth) ของโนดทั้งหมดใน tree คือ $O(log\ N)$ โดยมีสมมุติฐานว่าทุกลำดับการบรรจุโนดลงในทรีมีโอกาสเกิดขึ้นได้เท่า ๆ กันทุกรูปแบบของลำดับที่เป็นไปได้ ผลบวกของความลึกของโนดทุกโนดใน tree เรียกว่า internal path length เราจะคำนวณหาค่าเฉลี่ยของ internal path length ของ binary search tree โดยใช้ความเป็นไปได้ทั้งหมดของลำดับการบรรจุโนดลง binary search trees

ให้ $D(N)$ เป็น internal path length ของ tree T ใด ๆ ที่มีจำนวนโนดทั้งสิ้น $N$ โนด ซึ่ง $D(1) = 0$ และทรีที่มี N-node ประกอบด้วยโนดในทรีย่อยซ้ายจำนวน $i$ โนด และดังนั้นก็จะมีโนดในทรีย่อยขวาจำนวน $(N - i - 1)$ โนดรวมกับโนดที่รากที่ความลึกเป็นศูนย์ สำหรับ $0 < i < N$ ดังนั้น $D(i)$ จึงเป็นค่า internal path length ของทรีย่อยซ้ายเมื่อคิดจากโนดรากของตัวทรีย่อยเอง ถ้ามองจากทรีใหญ่เดิมเอง (main tree) แล้วโนดของทรีย่อยที่กล่าวเหล่านี้จะอยู่ในระดับลึกลงไปหนึ่งระดับ และสภาพดังที่กล่าวนี้ก็จะเป็นเช่นเดียวกันกับในทรีย่อยขวา ดังนั้น เราจะได้สมการ recurrence ดังนี้

$$D(N) = D(i) + D(N - i -1) + N -1$$

ถ้าขนาดของทรีย่อยที่เป็นไปได้มีโอกาสเกิดขึ้นได้ทุกขนาดเท่า ๆ กัน ก็หมายความว่า ค่าเฉลี่ยของทั้ง $D(i)$ และ $D(N - i -1)$ ก็คือ $(1/N) ∑_{j=0}^{N-1}D(j)$ ซึ่งจะได้

$$D(N)=2/N [∑_{j=0}^{N-1} D(j)]+N-1$$

ซึ่งเมื่อแก้สมการ recurrence (ดูหัวข้อ 7.7.5 บทที่ 7) นี้ก็จะได้ค่าเฉลี่ยเป็น $D(N) = O(N\ log\ N)$ ดังนั้น ค่า expected depth ของโนดใด ๆ ก็คือ $O(log\ N)$ รูปที่ 4.26 แสดงทรีที่สร้างจากตัวเลข 500 ตัว มีค่า expected depth เป็น 9.98

อย่างไรก็ตาม ไม่ใช่ว่าจะเป็นความจริงเสียทั้งหมดที่ average running time ของการทำงานต่าง ๆ ของทรีที่กล่าวมาจะเป็น $O(log\ N)$ เสมอ ทั้งนี้เนื่องจากมีการทำงานการลบ อีกทั้งไม่แน่นอนว่าทุก ๆ binary search trees จะมีโอกาสเกิดขึ้นได้เท่า ๆ กัน โดยเฉพาะอย่างยิ่งอัลกอริทึมของการลบที่ใช้นั้นจะทำให้ทรีย่อยซ้ายมีความลึกมากขึ้นกว่าทรีย่อยขวา เนื่องจากว่าเราจะใช้โนดจากทรีย่อยขวามาแทนที่โนดที่จะทำการลบ ผลที่แน่นอนของกรรมวิธีนี้ยังไม่เป็นที่ทราบแน่ชัดเป็นแต่เพียงคำที่กล่าวในทางทฤษฎีเท่านั้น มีผู้แสดงให้เห็นว่าถ้าทำการลบและบรรจุโนดสลับกันเป็นจำนวน $\Theta(N^2)$ ครั้งแล้ว จะได้ค่า expected depth เป็น $\Theta(\sqrt N)$ หลังทำการบรรจุ/ลบแบบสุ่มเป็นจำนวน ¼ ล้านครั้งในทรีรูปที่ 4.26 ทำให้ทรีที่ได้ไม่สมดุล (ความลึกเฉลี่ย = 12.51) ดังรูปที่ 4.27

ปัญหาที่กล่าวนี้สามารถขจัดได้ด้วยการสุ่มเลือกใช้สมาชิกที่มีค่าน้อยที่สุดในทรีย่อยขวาและสมาชิกที่มีค่ามากที่สุดในทรีย่อยซ้ายเพื่อนำมาแทนค่าลงในโนดที่ถูกลบ แต่ก็ยังไม่สามารถพิสูจน์ได้ว่าการทำเช่นนี้จะทำให้ในที่สุดแล้วทรีที่ได้จะสมดุล ประเด็นสำคัญของสิ่งที่กล่าวมาก็คือการกำหนดความหมายที่แท้จริงของคำว่า “เฉลี่ย” เป็นสิ่งที่ดูยุ่งยากมากอีกทั้งยังต้องอาศัยการใช้สมมติฐานหลาย ๆ อย่างที่อาจจะเป็นหรือไม่เป็นจริงก็ได้ แต่สำหรับกรณีที่ไม่มีการทำงานการลบหรือใช้การลบแบบ lazy deletion แล้วเราสามารถสรุปได้ว่าเวลา average running time ของการทำงานที่กล่าวมาแล้วนั้นก็จะเป็น $O(log N)$

ถ้าหากว่าชุดข้อมูลของอินพุตที่จะนำมาบรรจุลงในทรีเป็นชุดข้อมูลที่มีการจัดเรียงลำดับค่ามาก่อนแล้ว ก็จะทำให้ต้องใช้เวลาการบรรจุค่าชุดข้อมูลดังกล่าวนี้เป็น quadratic time และการทำงานจะแพงมากขึ้นในการใช้ linked list เนื่องจาก tree จะมีเฉพาะโนดที่ไม่มีทรีย่อยซ้าย วิธีการหนึ่งที่ใช้ในการแก้ปัญหาดังกล่าวนี้ คือการเพิ่มเติมเงื่อนไขของโครงสร้างให้รักษาการสภาพความสมดุล (balance) ของทรีเอาไว้ตลอดเวลา คือไม่ยอมให้มีโนดใดที่อยู่ลึกเกินไป


รูปที่ 4.26 Binary search tree ที่สร้างด้วยข้อมูลแบบสุ่ม


รูปที่ 4.27 Binary search tree หลังจากการทำงานค้วยคู่ insert/remove เป็นจำนวน $\Theta(N^2)$ ครั้ง

มีอัลกอริทึมไม่มากนักที่ใช้ในการจัดการกับทรีแบบสมดุลและที่มีก็จะค่อนข้างซับซ้อนกว่าการใช้ binary search tree และใช้เวลาในการปรับปรุงค่ามากกว่า หัวข้อข้างหน้าจะได้กล่าวถึงทรีที่เรียกว่า AVL tree ซึ่งเป็นรูปแบบหนึ่งของ search tree แบบสมดุล

อีกวิธีที่จะกล่าวถึงเป็นวิธีที่ใหม่กว่า AVL เรียกว่า spray tree โครงสร้างทรีแบบนี้ไม่ได้กำหนดเรื่องความสมดุลแต่แรกและยอมให้ tree มีความลึกเท่าไรก็ได้ แต่หลังการดำเนินการใด ๆ แล้วก็จะต้องดำเนินการตามกฎการจัดโครงสร้างใหม่ที่จะทำให้การทำงานคราวต่อ ๆ มามีประสิทธิภาพมากขึ้น การทำเช่นนี้ไม่ประกันว่าการดำเนินการใด ๆ ครั้งหนึ่งจะมี time bound เป็น $O(log\ N)$ แต่การดำเนินการที่ต่อเนื่องกัน $$ ครั้ง จะใช้เวลาทั้งสิ้นเป็น $O(M\ log\ N)$ ในกรณี worst case