md-platform

segment-tree-project.md
View raw Back to list

Project Template – Algorithm Design

Studenti: _____________________ Data: _____________________ Lënda: Algorithm Design


1. Qëllimi i Projektit

Problemi që do të zgjidhim

Në shumë sisteme softuerike, kemi nevojë të punojmë me sasi të mëdha të dhënash që ndryshojnë vazhdimisht, dhe njëkohësisht duhet të përgjigjemi shpejt në pyetje mbi grupe të caktuara të atyre të dhënave.

Për shembull, le ta marrim rastin e një kompanie me qindra degë marketesh në mbarë vendin. Çdo degë raporton shitjet e saj çdo ditë. Menaxhmenti ka nevojë të dijë përgjigje si:

Nëse i ruajmë shitjet në një listë të thjeshtë dhe i mbledhim një nga një sa herë që na duhet përgjigjja, kjo bëhet tepër e ngadaltë kur kemi mijëra degë dhe mijëra pyetje në ditë. Nga ana tjetër, nëse e parallogarisim shumën totale paraprakisht, atëherë çdo ndryshim i vetëm na detyron ta rillogarisim gjithçka.

Ky tension midis pyetjeve të shpejta dhe ndryshimeve të shpejta është problemi thelbësor që do ta adresojmë.

Objektivat kryesore


2. Përshkrimi i Problemit

Konteksti — Problemi i botës reale

Para se të flasim për zgjidhjen teknike, duhet ta kuptojmë mirë problemin që ekziston në praktikë.

Një zinxhir marketesh ka 1000 degë të shpërndara në mbarë vendin. Çdo degë raporton shitjet ditore në sistemin qendror. Menaxherët e rajoneve kanë nevojë të dinë vazhdimisht:

Këto pyetje vijnë dhjetëra herë në ditë nga menaxherë të ndryshëm, për rajone të ndryshme. Njëkohësisht, degët vazhdojnë të raportojnë shitje të reja, pra vlerat ndryshojnë gjatë gjithë kohës.

Me një listë të thjeshtë, për t'i mbledhur vlerat e 200 degëve do të na duheshin 200 hapa. Me 1000 pyetje në ditë, kjo do të thoshte 200,000 operacione — shumë e ngadaltë kur sistemi duhet të përgjigjet në çast.

Na duhet një strukturë që na lejon t'i bëjmë të dy gjërat shpejt: pyetje mbi range dhe përditësime individuale.

Formulimi teknik i problemit

Duke e abstraguar rastin e mësipërm, problemi teknik që do të zgjidhim është:

Na jepet një array A me n numra të plotë, ku çdo element përfaqëson shitjen e një dege. Duhet të procesojmë q operacione, ku secili operacion është njëri nga dy llojet:

Input dhe Output

Input:

n = 8                                    (numri i degëve)
A = [10, 25, 15, 30, 20, 45, 35, 50]    (shitjet ditore në €)
q = 4                                    (numri i operacioneve)

Operacionet:
  Query(2, 5)       → Kthe shitjet totale nga dega 2 deri 5
  Update(3, 50)     → Dega 3 ka raportuar 50€
  Query(2, 5)       → Kthe shumën e re pas përditësimit
  Query(0, 7)       → Kthe shitjet totale të gjitha degëve

Output:

Query(2, 5) = 110    (15 + 30 + 20 + 45)
Update(3, 50)        (A bëhet [10, 25, 15, 50, 20, 45, 35, 50])
Query(2, 5) = 130    (15 + 50 + 20 + 45)
Query(0, 7) = 250    (10 + 25 + 15 + 50 + 20 + 45 + 35 + 50)

Kufizimet / Constraints

Parametri Vlera
n (numri i degëve) 1 ≤ n ≤ 10⁶
q (numri i operacioneve) 1 ≤ q ≤ 10⁶
A[i] (shitjet në €) -10⁹ ≤ A[i] ≤ 10⁹
l, r (range i query-t) 0 ≤ l ≤ r < n
Koha e lejuar ~2 sekonda

3. Zgjedhja e Algoritmit

Algoritmi i zgjedhur: Divide & Conquer → Segment Tree

Segment Tree-n e kemi zgjedhur sepse kombinon dy parime:

Pse e kemi zgjedhur këtë algoritëm

Para se ta zgjidhnim Segment Tree-n, i kemi shqyrtuar edhe alternativat:

Qasja Query Update A përshtatet?
Brute Force (loop) O(n) O(1) Jo — query tepër i ngadaltë
Prefix Sum O(1) O(n) Jo — update tepër i ngadaltë
Segment Tree O(log n) O(log n) Po — i balancuar

Siç e shohim nga tabela, Segment Tree është i vetmi që e balancon kohën e query-t dhe update-it. Kur kemi shumë operacione nga të dy llojet, asnjë alternativë tjetër nuk e arrin këtë balancim.

Pseudo-code

FUNKSIONI BUILD(arr, node, start, end):
    NËSE start == end:
        tree[node] = arr[start]                         // gjethja = elementi
    PËRNDRYSHE:
        mid = (start + end) / 2
        BUILD(arr, 2*node, start, mid)                   // gjysma e majtë
        BUILD(arr, 2*node+1, mid+1, end)                 // gjysma e djathtë
        tree[node] = tree[2*node] + tree[2*node+1]       // prindi = shuma

FUNKSIONI QUERY(node, start, end, l, r):
    NËSE r < start OSE end < l:
        KTHE 0                                           // jashtë range-it
    NËSE l <= start DHE end <= r:
        KTHE tree[node]                                  // brenda plotësisht
    mid = (start + end) / 2
    KTHE QUERY(majta) + QUERY(djathta)                   // ndaje dhe mblidh

FUNKSIONI UPDATE(node, start, end, idx, val):
    NËSE start == end:
        tree[node] = val                                 // ndrysho gjethën
    PËRNDRYSHE:
        Zbrit në gjysmën ku ndodhet idx
        tree[node] = tree[majta] + tree[djathta]         // rillogarit prindin

Flowchart — Si funksionon Query(2, 5)

                    [0-7] 230
                  /            \
           [0-3] 80            [4-7] 150
          /      \             /       \
      [0-1]35   [2-3]45   [4-5]65   [6-7]85
      /  \      /   \      /  \      /  \
    10   25   15    30   20   45   35   50

Hapi 1: Rrënja [0-7] → range 2-5 është pjesërisht brenda → zbresim
Hapi 2: [0-3] → range 2-5 është pjesërisht brenda → zbresim
Hapi 3: [0-1] → range 2-5 është JASHTË → kthejmë 0
Hapi 4: [2-3] → range 2-5 është BRENDA PLOTËSISHT → e marrim 45 ✓
Hapi 5: [4-7] → range 2-5 është pjesërisht brenda → zbresim
Hapi 6: [4-5] → range 2-5 është BRENDA PLOTËSISHT → e marrim 65 ✓
Hapi 7: [6-7] → range 2-5 është JASHTË → kthejmë 0

Rezultati: 0 + 45 + 65 + 0 = 110 ✓

4. Implementimi

Gjuha e programimit: C# (.NET 8)

Strukturat e të dhënave të përdorura

Struktura Përdorimi
int[] _tree Ruan nyjet e pemës (madhësia 4×n)
int _n Madhësia e array-it origjinal

Pse 4 × n?

Pema binare me n gjethë ka maksimumi 2n - 1 nyje. Megjithatë, meqë e kemi ruajtur pemën si array me indeksim 2*node dhe 2*node+1, na duhen deri në 4n pozita për ta mbuluar rastin më të keq. Kështu, kemi zgjedhur 4 * n si madhësi të sigurt.

Kodi i plotë

namespace SegmentTreeDemo;

/// <summary>
/// Segment Tree — strukturë e të dhënave që mundëson range query
/// dhe point update në O(log n) kohë.
/// </summary>
public class SegmentTree
{
    private readonly int[] _tree;
    private readonly int _n;

    /// <summary>
    /// Ndërton Segment Tree nga array-i i dhënë.
    /// Kompleksiteti: O(n)
    /// </summary>
    public SegmentTree(int[] arr)
    {
        _n = arr.Length;
        _tree = new int[4 * _n];
        Build(arr, node: 1, start: 0, end: _n - 1);
    }

    /// <summary>
    /// Kthen shumën e elementeve nga pozita l deri në r (përfshirëse).
    /// Kompleksiteti: O(log n)
    /// </summary>
    public int Query(int l, int r)
    {
        return Query(node: 1, start: 0, end: _n - 1, l, r);
    }

    /// <summary>
    /// Ndryshon elementin në pozitën idx me vlerën e re val.
    /// Kompleksiteti: O(log n)
    /// </summary>
    public void Update(int idx, int val)
    {
        Update(node: 1, start: 0, end: _n - 1, idx, val);
    }

    // ================================================================
    // METODAT PRIVATE
    // ================================================================

    /// <summary>
    /// Ndërton pemën rekursivisht.
    /// Çdo gjethë mban një element të array-it.
    /// Çdo nyje prind mban shumën e dy fëmijëve.
    /// </summary>
    private void Build(int[] arr, int node, int start, int end)
    {
        if (start == end)
        {
            // Rasti bazë: kemi arritur te gjethja.
            // E ruajmë elementin e array-it direkt në nyje.
            _tree[node] = arr[start];
            return;
        }

        int mid = (start + end) / 2;

        // Ndërtojmë nënpemën e majtë (gjysma e parë e segmentit)
        Build(arr, node: 2 * node, start, end: mid);

        // Ndërtojmë nënpemën e djathtë (gjysma e dytë e segmentit)
        Build(arr, node: 2 * node + 1, start: mid + 1, end);

        // Nyja prind = shuma e dy fëmijëve
        _tree[node] = _tree[2 * node] + _tree[2 * node + 1];
    }

    /// <summary>
    /// Gjen shumën e elementeve në range-in [l, r].
    /// Punon duke zbritur pemën dhe duke kombinuar vetëm
    /// segmentet që preken nga range-i i kërkuar.
    /// </summary>
    private int Query(int node, int start, int end, int l, int r)
    {
        // RASTI 1: Segmenti [start, end] nuk prek fare range-in [l, r].
        // Shembull: po kërkojmë [2, 5] por jemi në nyjen [6, 7].
        // Ky segment nuk kontribuon asgjë, pra kthejmë 0.
        if (r < start || end < l)
        {
            return 0;
        }

        // RASTI 2: Segmenti [start, end] bie plotësisht brenda [l, r].
        // Shembull: po kërkojmë [2, 5] dhe jemi në nyjen [2, 3].
        // E marrim vlerën e kësaj nyje direkt, pa pasur nevojë të zbresim më thellë.
        if (l <= start && end <= r)
        {
            return _tree[node];
        }

        // RASTI 3: Segmenti mbulohet pjesërisht — e ndajmë në dy gjysma
        // dhe i mbledhim rezultatet.
        int mid = (start + end) / 2;
        int leftSum = Query(node: 2 * node, start, end: mid, l, r);
        int rightSum = Query(node: 2 * node + 1, start: mid + 1, end, l, r);

        return leftSum + rightSum;
    }

    /// <summary>
    /// Ndryshon vlerën e elementit në pozitën idx.
    /// Zbret deri te gjethja, e ndryshon, dhe pastaj rillogarit
    /// çdo nyje prind në rrugën kthyese deri në rrënjë.
    /// </summary>
    private void Update(int node, int start, int end, int idx, int val)
    {
        if (start == end)
        {
            // Kemi arritur te gjethja që duam ta ndryshojmë.
            // E vendosim vlerën e re.
            _tree[node] = val;
            return;
        }

        int mid = (start + end) / 2;

        if (idx <= mid)
        {
            // Elementi ndodhet në gjysmën e majtë — zbresim majtas
            Update(node: 2 * node, start, end: mid, idx, val);
        }
        else
        {
            // Elementi ndodhet në gjysmën e djathtë — zbresim djathtas
            Update(node: 2 * node + 1, start: mid + 1, end, idx, val);
        }

        // Pasi e kemi ndryshuar gjethën, rillogarisim nyjen prind
        // duke mbledhur dy fëmijët e saj.
        _tree[node] = _tree[2 * node] + _tree[2 * node + 1];
    }
}

Programi kryesor — Demonstrimi

namespace SegmentTreeDemo;

public class Program
{
    public static void Main()
    {
        //            dega0  dega1  dega2  dega3  dega4  dega5  dega6  dega7
        int[] shitjet = [10,   25,    15,    30,    20,    45,    35,    50];

        var st = new SegmentTree(shitjet);

        // Query 1: Kërkojmë shumën e degëve 2-5
        Console.WriteLine($"Query(2,5) = {st.Query(2, 5)}");   // 110

        // Update: Dega 3 tani ka 50€ në vend të 30€
        st.Update(3, 50);
        Console.WriteLine("Update(3, 50) — dega 3 u përditësua");

        // Query 2: E bëjmë të njëjtën pyetje pas ndryshimit
        Console.WriteLine($"Query(2,5) = {st.Query(2, 5)}");   // 130

        // Query 3: Kërkojmë shumën totale të gjitha degëve
        Console.WriteLine($"Query(0,7) = {st.Query(0, 7)}");   // 250
    }
}

Output i pritshëm:

Query(2,5) = 110
Update(3, 50) — dega 3 u përditësua
Query(2,5) = 130
Query(0,7) = 250

Hapat kryesorë të implementimit

  1. Alokimi i memories — Krijojmë int[] _tree me madhësi 4 * n
  2. Ndërtimi (Build) — E mbushim pemën rekursivisht nga array-i, O(n)
  3. Query — Zbresim pemën, marrim vetëm segmentet relevante, O(log n)
  4. Update — Ndryshojmë gjethën, rillogarisim prindërit deri në rrënjë, O(log n)

5. Testimi

Për ta verifikuar korrektësinë e implementimit, kemi përgatitur gjashtë test case që mbulojnë raste normale, ndryshime, dhe edge case.

Test Case 1 — Query bazik

Input:  A = [10, 25, 15, 30, 20, 45, 35, 50]
        Query(2, 5)
Pritur: 110 (15 + 30 + 20 + 45)
Marrë:  110 ✓

Test Case 2 — Update pastaj Query

Input:  Update(3, 50)    → A bëhet [10, 25, 15, 50, 20, 45, 35, 50]
        Query(2, 5)
Pritur: 130 (15 + 50 + 20 + 45)
Marrë:  130 ✓

Test Case 3 — Query i plotë (i gjithë array-i)

Input:  Query(0, 7)
Pritur: 250 (10 + 25 + 15 + 50 + 20 + 45 + 35 + 50)
Marrë:  250 ✓

Test Case 4 — Query për një element të vetëm

Input:  Query(4, 4)
Pritur: 20
Marrë:  20 ✓

Test Case 5 — Edge Case: Array me 1 element

Input:  A = [42]
        Query(0, 0)
Pritur: 42
Marrë:  42 ✓

Test Case 6 — Numra negativë

Input:  A = [-5, 10, -3, 8]
        Query(0, 3)
Pritur: 10 (-5 + 10 + -3 + 8)
Marrë:  10 ✓

Analiza e saktësisë

Të gjashtë test case-et kanë kaluar me sukses. Rezultatet e marra përputhen plotësisht me rezultatet e pritura. Testimi ynë ka mbuluar:


6. Analiza e Kompleksitetit

Time Complexity

Operacioni Kompleksiteti Arsyeja
Build O(n) Vizitojmë çdo nyje saktësisht një herë. Pema ka ~2n nyje
Query O(log n) Në çdo nivel të pemës, vizitojmë maksimumi 4 nyje. Pema ka log₂(n) nivele
Update O(log n) Ndjekim vetëm një shteg nga rrënja deri te gjethja — sa lartësia e pemës

Për q operacione totale: O(n + q × log n)

Space Complexity

Komponenti Hapësira
Array-i _tree O(4n) = O(n)
Stack i rekursionit O(log n) — thellësia e pemës
Totale O(n)

Faktorët që ndikojnë në performancë

1. Madhësia e array-it (n) Kur n rritet, ndërtimi merr më shumë kohë, por query dhe update mbeten O(log n). Për shembull, me n = 1,000,000 degë, log₂(n) ≈ 20, që do të thotë se çdo query do të bëjë vetëm ~20 krahasime.

2. Numri i operacioneve (q) Segment Tree shkëlqen kur q është i madh — domethënë kur kemi shumë pyetje dhe shumë ndryshime njëkohësisht. Nëse do të kishim vetëm 1-2 pyetje pa ndryshime, brute force do të mjaftonte.

3. Cache performance Meqë pemën e kemi ruajtur si array (jo me pointera), kemi përfitim nga cache locality — nyjet fqinje në pemë ndodhen afër edhe në memorie, gjë që e shpejton aksesimin.

4. Tipi i operacionit Segment Tree funksionon me çdo operacion që është asocativ: SUM, MIN, MAX, GCD, XOR, etj. Kushti është që a ⊕ (b ⊕ c) = (a ⊕ b) ⊕ c.


7. Krahasimi me Algoritme të Tjerë

Alternativa 1: Brute Force (Loop i thjeshtë)

Për Query(l, r): shkojmë nga l deri r, mbledhim vlerat
Për Update(i, v): A[i] = v
Brute Force Segment Tree
Query O(n) O(log n)
Update O(1) O(log n)
Build O(1) O(n)
Hapësira O(n) O(4n)

Kur do ta zgjidhnim Brute Force? Kur kemi shumë update por pak query, ose kur n është shumë i vogël (nën 100 elemente).

Alternativa 2: Prefix Sum

Ndërtojmë: prefix[i] = A[0] + A[1] + ... + A[i]
Query(l, r) = prefix[r] - prefix[l-1]
Update(i, v) = rillogarisim prefix nga i deri n
Prefix Sum Segment Tree
Query O(1) O(log n)
Update O(n) O(log n)
Build O(n) O(n)

Kur do ta zgjidhnim Prefix Sum? Kur nuk kemi update fare — vetëm pyetje. Në atë rast, query O(1) është e pakapërcyeshme.

Alternativa 3: Binary Indexed Tree (BIT / Fenwick Tree)

BIT Segment Tree
Query O(log n) O(log n)
Update O(log n) O(log n)
Hapësira O(n) O(4n)
Implementimi Më i thjeshtë Më i ndërlikuar
Fleksibiliteti Vetëm SUM SUM, MIN, MAX, etj.

Kur do ta zgjidhnim BIT? Kur na duhet vetëm operacioni SUM dhe dëshirojmë kod më të shkurtër me më pak memorie.

Tabela përmbledhëse — Kur ta përdorim secilin

Vetëm Query pa Update?  →  Prefix Sum
Vetëm SUM?              →  BIT (më i thjeshtë)
SUM + MIN + MAX?        →  Segment Tree ✓
n < 100?                →  Brute Force

Segment Tree-n e kemi zgjedhur sepse na ofron fleksibilitet maksimal — mbështet çdo operacion asocativ dhe ka performancë të balancuar si për query ashtu edhe për update.


8. Përfundimet

Çfarë kemi mësuar nga ky projekt

  1. Divide & Conquer nuk përdoret vetëm për sorting — me të kemi ndërtuar një strukturë të fuqishme të dhënash
  2. Kemi kuptuar trade-off-in midis kohës dhe hapësirës — Segment Tree përdor 4× më shumë memorie, por na fiton shpejtësi eksponenciale në query
  3. Rekursioni ka qenë çelësi — ndërtimi, query, dhe update janë të gjitha rekursive me strukturë të ngjashme, gjë që e bën kodin elegant
  4. Kemi mësuar si të zgjedhim algoritmin e duhur duke krahasuar kompleksitetin e alternativave me kërkesat e problemit

Përmirësime të mundshme

Përmirësimi Përshkrimi
Lazy Propagation Do të na lejonte range update (p.sh. "shto 5 te gjitha degët 10-50") në O(log n)
Iterative Build Do ta eliminonte rekursionin duke ndërtuar bottom-up — pak më i shpejtë
Persistent Segment Tree Do të na ruante versionet e kaluara — e dobishme për time-travel queries
Generic<T> Do ta bënim klasën generike në C# për të mbështetur tipe të ndryshme (long, double)

Përdorimi real i këtij algoritmi


9. Referencat

Burimi Përshkrimi
Introduction to Algorithms (CLRS) Kapitulli mbi Divide & Conquer dhe Data Structures
Competitive Programmer's Handbook Antti Laaksonen — Kapitulli 9: Range Queries
CP-Algorithms cp-algorithms.com — Segment Tree
GeeksforGeeks geeksforgeeks.org — Segment Tree: Sum of given range
Microsoft Learn Dokumentacioni zyrtar i C# dhe .NET 8
Visualgo visualgo.net — Vizualizim interaktiv i Segment Tree