Back to Basic: Fundamental Data Structure in C#

สำหรับ Blog นี้ ผมเขียนมาประกอบกับ Talk ที่ลองส่งในงาน .NET Developer Day 2025 - Thailand ครับ โดยแรงบันดาลใจในการเขียนเรื่องนี้ มาจากที่ว่าช่วงเดือนที่แล้วได้เข้าไปช่วยดับไฟ ในส่วนของ Performance Test ซึ่งมันเป็นในส่วน WEB API / WinApp ด้วย โดยสาเหตุที่ทำให้ Process มันช้า เกิดจากการเลือก Data Structure ที่ผิดประเภทมันมีหลายตัวนะ แต่ผมเจอแต่คนใช้ IList<T> / List<T> แทบจะทุกเรื่องเลย ถ้า Data มันเล็กๆมันจะไม่เห็นผลเท่าไหร่ แต่ถ้าข้อมูลมันเพิ่มจากหลักสิบ มาเป็นร้อย หมืน แสน ล้าน มันจะส่งผลได้ชัดเจนเลยครับ

ปัญหาที่พบ

- Component MoverBox มันตอบสนองช้ามาก เวลาเลือกข้อมูลจำนวนเยอะ

MoverBox ตัวที่ช่วยเลือกข้อมูลเยอะ โดยทำมาจาก DevExpress นี้แหละ มันอยู่มานานและน่าจะเกิน 10 ปีได้ โดยมี

  • Grid Source / Grid Destination ซึ่ง User สามารถใช้ Feature ของ Grid Filter ข้อมูลได้
  • มีปุ่ม >> / << เลือกทั้งหมดไปทาง Source / Destination
  • มีปุ่ม > / < เลือกบางส่วน Source / Destination
  • ข้อมูลใน Grid จะ Unique เสมอ
  • สำหรับ Data Structure ที่ใช้จะเป็น List<T>
- Process ต่างๆ มันกินเวลามาขึ้น อย่างมีนัยสำคัญ เมื่อข้อมูลเพิ่มขึ้น

ใน Loop นอกจาก for แล้ว ยังมี Where หรือ FirstOrDefault หาใน List อีก ตัวอย่างจะประมาณนี้

public static void ProcessTransactionWithOnlyList(IList<TransactionDTO> pTransactions, IList<PortfolioDTO> pPortfolios, IList<PortPriceSouceDTO> pPortPriceSources)
{
    // Iterate through transactions and find corresponding portfolio
    foreach (var transaction in pTransactions)
    {
        // Use LINQ to find the portfolio with the matching PortfolioId
        var portfolio = pPortfolios.FirstOrDefault(p => p.PortfolioId == transaction.PortfolioId);
        if (portfolio != null)
        {
            // You can save or process the transaction with the portfolio here
            //....
        }

        var portPriceSource = pPortPriceSources.FirstOrDefault(p => p.PortfolioId == transaction.PortfolioId);
        if (portPriceSource != null)
        {
            // You can save or process the transaction with the portfolio here
            //....
        }
    }
}

Recap Data Structure

ไม่แน่ใจว่าคนอื่นเจอเหมือนกันไหม แต่ผมเจอคนที่ใช้ List แทน Data Structure ทุกอย่างเลย 55 มาทบทวนกันก่อน

- Data Structure + Big O คือ อะไร

สำหรับผม A way to organize a information to Add / Retrieve / Remove / Search efficiency เอาง่ายเป็นวิธีการจัดการข้อมูล มันมี Collection ที่ดีมาให้เลือก เราใช้ตาม use-case ที่เหมาะสมพอ โดยที่แต่ละ Operation Add / Retrieve / Remove / Search มันมีวิธีการวัด มันชื่อว่า Big O ไม่ต้องจำเยอะ จำสั้นๆ O(1) ดี แต่ถ้าแบบ O(n) หรือ O(n2) พวก Loop ซ้่อนกันหลายชั้น ไม่ดีแล้ว

- C# Data Structure

มันมีหลายตัว ตามรูปเลย

Ref: Book: C# Data Structures and Algorithms(Marcin Jamro)

แต่เอาจริงเราใช้กันไม่หมดหรอก หลักผมใช้ List / Dictionary / Queue / HashSet ครับ

- Lists (List<T>)

Use Case:

  • ใช้กรณีที่ต้องการเก็บต่อกันไป แต่เราไม่รู้ขนาด (dynamic size)
  • Simple เอาไปใช้ได้หลายรูปแบบ

Note: Array Fixed Size

Big O Complexity:

  • Access: O(1)
  • Search: O(n)
  • Insert: O(n) (O(1) if adding at the end)
  • Delete: O(n)

Sample Code:
🚅 https://dotnetfiddle.net/CeShV4
🚅 https://dotnetfiddle.net/EN9v51 (C# Built-In InsertRange สำหรับแทรกกลาง)

- Dictionary<TKey, TValue>

Use Case:

  • ใช้กรณีต้องการเก็บข้อมูลในรูปแบบ key / value เอา key มาของ
  • Simple Caching / Lookup / Count Freq

Big O Complexity:

  • Access: O(1)
  • Search: O(1)
  • Insert: O(1)
  • Delete: O(1)

Sample Code:
🚅 https://dotnetfiddle.net/fjmgK1

จริงๆ ปัญหาที่เจอบ่อย การใช้ List แบบผิดๆ ใช้ list.where( x => x.yourkey) / list.FirstOrDefault( x => x.yourkey) อยากได้เป็นตัว Lookup ถ้าแปลงข้อมูลในอยู่รูปแบบ Dictionary (key / value) จะช่วยลด Cost จาก Search: O(n) > Search: O(1)

- HashSet<T>

Use Case:

  • ใช้กรณีต้องการเก็บข้อมูลที่ไม่ซ้ำ (Unique) เช่น ต้องการข้อมูลใน Lookup ที่ไม่ซ้ำ
  • การวัดว่าไม่ซ้ำ อย่าลืม implement Equals / GetHashCode

Big O Complexity:

  • Access: O(1)
  • Search: O(1)
  • Insert: O(1)
  • Delete: O(1)

Sample Code:
🚅 https://dotnetfiddle.net/KjkiPn

- Stack<T> & Queue

Use Case:

  • Stacks - ใช้ Last-In-First-Out (LIFO) เข้าก่อนออกที่หลัง ตัวอย่างที่ใช้
    - การทำ Undo/Redo operation
    - Syntaxes / Expression Parsing - check html tags
  • Queues ใช้ First-In-First-Out (FIFO) เข้าออกตามลำดับ ตัวอย่างที่ใช้
    - Simple Message Queue
    - Job scheduling with/without priority

Sample Code:
🚅 https://dotnetfiddle.net/5R2eAo (Stack)
🚅 https://dotnetfiddle.net/lPcTST (Queue)

Big O Complexity:

  • Access: O(n)
  • Search: O(n)
  • Insert: O(1) Enqueue / Push
  • Delete: O(1) Deque  / Pop

อันนี้เขียนไปเป็นเพียงตัวอย่างนะ เพราะมันใน dotnet มันมี Data Structure หลายแบบเลย อาทิ เช่น

สำหรับพวก Generic พวก DTO ต่างๆ อย่างลืมไป Implement Equals / GetHashCode ถ้าอยากรู้ว่ามัน คือ อะไรลองดูใน Blog นี้ได้นะ จะได้มีวิธีวัดว่าอะไรที่บอกวา่ Object นั้น Unique

แล้วแก้ไขยังไง ?

- Component MoverBox มันตอบสนองช้ามาก เวลาเลือกข้อมูลจำนวนเยอะ

จากเดิมใช้ IList<T> ในการเก็บข้อมูลฝั่ง Source / Desc เราเปลี่ยน Data Structure ที่ใช้งานเลยมาเป็น HashSet<T> เพราะข้อมูลใน MoverBox มัน Unique อยู่แล้ว

Sample Code: https://dotnetfiddle.net/MrFiYg (List vs Hash Map)

นอกจากส่วนของการเลือกใช้ Data Structure แล้ว ยังมี Step ของการ Call 3rd Party API เรา Call บ่อยๆ ในทีนี้จะเป็นตัว DevExpress มันจะมี latency เกิดขึ้น เป็นไปได้ ดึงมาทั้งหมดก่อน แล้วมา Process หยิบทีเดียว

  • Old Code
int[] sourceIndex = new int[this._sourceGridView.SelectedRowsCount];
for (int row = this._sourceGridView.SelectedRowsCount - 1; row >= 0; row--){
    if (_sourceGridView.GetSelectedRows()[row] >= 0){
        sourceIndex[row] = this._sourceGridView.GetDataSourceRowIndex(this._sourceGridView.GetSelectedRows()[row]);
    }
    else{
        sourceIndex[row] = this._sourceGridView.GetSelectedRows()[row];
    }
}
  • New Code - ปรับไปแล้ว ข้อมูลที่หมื่น Record ใน Grid จากเดิมรอไป 2-3 นาที ตอนนี้แปบเดียวครับ
int[] selectedRows = this._sourceGridView.GetSelectedRows();
int[] sourceIndex = new int[selectedRows.Length];

for (int row = 0; row < selectedRows.Length; row++){
    if (selectedRows[row] >= 0){
        sourceIndex[row] = this._sourceGridView.GetDataSourceRowIndex(selectedRows[row]);
    }
    else{
        sourceIndex[row] = selectedRows[row];
    }
}

อันนี้จริงๆเหมือนเป็นการแก้แบบ Workarounds นะ เพราะข้อมูลที่มาแสน หรือหลักล้าน มาให้เลือกทีละอัน แปลกๆอยู่ดี อาจจะต้องเอา UX/UI มาช่วยต่อครับ

- Process ต่างๆ มันกินเวลามาขึ้น อย่างมีนัยสำคัญ เมื่อข้อมูลเพิ่มขึ้น

ทางแก้พยายามเปลี่ยน Preprocess ข้อมูลก่อน อาจะที่แบบมันเป็นแนว Lookup ให้ทำเป็น Dictionary ไว้ก่อนเข้า Loop

public static void ProcessTransactionWithListAndDic(IList<TransactionDTO> pTransactions, IDictionary<int, PortfolioDTO> pPortDic, IDictionary<int, PortPriceSouceDTO> pPortPriceSourceDic)
{
    foreach (var transaction in pTransactions)
    {
        var portfolio = pPortDic.ContainsKey(transaction.PortfolioId) ? pPortDic[transaction.PortfolioId] : null;
        if (portfolio != null)
        {
            // You can save or process the transaction with the portfolio here
            //....
        }

        var portPriceSource = pPortPriceSourceDic.ContainsKey(transaction.PortfolioId) ? pPortPriceSourceDic[transaction.PortfolioId] : null;
        if (portPriceSource != null)
        {
            // You can save or process the transaction with the portfolio here
            //....
        }
    }
}

Sample Code: https://dotnetfiddle.net/h07dZg 

ถ้าเราเลือกจับคู่ Data Stucture ผิด ผลที่ได้มันต่างกันเลยนะ และเป็นเคสที่เจอประจำด้วย

Summary

สรุปตามรูปเลย

  1. เราต้องเข้าใจปัญหาก่อน เพราะของผมว่าะเจอได้ทุกคนไปคิดว่ามันเกิดจากให้ vCPU น้อยไป หรือ Database Index ไม่เพิ่มพอต้องเพิ่ม
  2. เลือก Data Structure ให้เหมาะกับงาน ดูจาก Size / Access Pattern / ความถี่ในการแก้ไข / มันทำงาน Single Thread หรือ Multi-Thread รวมถึงพวก Constraint อื่นๆ
  3. ก่อนจะใช่อะไร อ่าน doc กอนครับ ^__^

และการเลือก Data Structure + Algorithm เป็นจุดเริ่มต้นที่ดีให้ระบบของเรา ไม่เกิดปัญหาด้าน Performance แต่มันมีอีกหลายส่วนประกอบนะ เช่น Architecture Design / Database Schema Design / You Logic เช่น Algorithm, Business, Sort / SQL ที่เขียนนี่แหละ !!!!!

ปิดท้ายด้วยตารางสรุปแนวทางการในเลือก Data Structure (Generic)

Data StructureBest ForAvoid WhenKey StrengthKey Weakness
ArrayFixed-size, direct indexingFrequent size changesFast accessFixed size
ListDynamic collection with index accessMany inserts in middleFlexible sizeMiddle insertions
DictionaryKey-value lookupsOrdered data neededFast lookupsNo ordering
LinkedListFrequent inserts/deletesRandom access neededFast modificationsSlow lookups
StackLIFO processingRandom access neededSimple modelLimited access pattern
QueueFIFO processingRandom access neededSimple modelLimited access pattern
HashSetUnique item collectionOrdering neededFast lookupsNo duplicates
SortedDictionaryOrdered key-value pairsHuge collections with frequent changesOrdered + lookupSlower than Dictionary
SortedSetOrdered unique itemsMany duplicates neededOrdered uniquenessSlower than HashSet
PriorityQueuePriority-based processingFIFO/LIFO neededPriority handlingComplex interface
แนวทางการในเลือก Data Structure

Resource: Slide / Source Code


Discover more from naiwaen@DebuggingSoft

Subscribe to get the latest posts sent to your email.