big-O ביטוי ליעילות הקוד

מחבר:
בתאריך:

אחד הנושאים החשובים ביותר שחייבים לעניין כל מתכנת הוא האם הקוד שלו יעיל. זה חשוב כי קוד לא יעיל מאט תהליכים ועלול לגרום לקריסה של המערכת או לנזק כספי משמעותי. ביטוי big-O מתאר את התארכות משך הזמן של ביצוע משימה ביחס לגידול כמות המידע בו מזינים את התהליך.

במדריך זה נציג כמה אופנים נפוצים לתיאור התארכות משך הזמן לביצוע הקוד ביחס לכמות המידע באמצעות ביטוי big-O notation.

Big-O ביטוי ליעילות הקוד

 

זמן ריצה קבוע O(1)

קוד שמשך זמן הביצוע שלו קבוע ללא תלות בכמות המידע שמזינים לתוכו מתואר כ- O(1).

  • ה-O ואחריו סוגריים עגולים הם מוסכמה כשרוצים לתאר אלגוריתם באמצעות big-O.
  • 1 בתוך הסוגריים העגולים מתאר זמן ריצה קבוע.

לדוגמה, פונקציה אשר מקבלת רשימה כפרמטר ובוחנת האם הערך של הפריט הראשון ברשימה הוא 1:

def is_first_1(list):
    print(list[0] == 1)

נזין אותה בטווח של ערכים שמתחיל מ-0, ונבדוק האם הפריט הראשון ברשימה הוא 1:

numbers = range(3)
is_first_1(numbers)

התוצאה:

False
  • זה לא משנה מה אורך הרשימה זמן הביצוע יהיה קבוע פחות או יותר כי משך הזמן שלוקח למחשב להציב ערך לפריט מסוים ברשימה לא משתנה לא משנה כמה ארוכה הרשימה.

קוד שמשך הזמן של הביצוע שלו נותר קבוע ללא תלות בכמות המידע שמזינים לתוכו מכונה O(1).

כשהרצתי את הקוד על מספר מערכים שאורכם נע בין 10 ל-10 מיליון קיבלתי תוצאה שהיא כמעט קו ישר אופקי:

graph depicting the big o constant function

  • ציר ה-X הוא N - מספר הפריטים בקלט. ציר ה-Y הוא משך הריצה של הפונקציה בשניות.
  • בסה"כ ההתנהגות היא כמצופה. את הקפיצה בהתחלה אפשר להסביר על ידי תהליכים אחרים שלא קשורים באלגוריתם עצמו שמכניסים רעש לתוצאות.

 

גידול קווי במשך זמן הריצה O(N)

קוד שמשך הריצה שלו מתארך באופן קווי (ליניארי) ככל שגדלה כמות המידע שמזינים לתוכו מכונה O(N).

  • ה-O ואחריו סוגריים עגולים הם מוסכמה כשרוצים לתאר אלגוריתם בשיטת big-O.
  • N הוא משתנה המתאר את מספר הפריטים שמקבלת פונקציה כקלט (input).

לדוגמה, פונקציה שבודקת האם מספר מסוים קיים בתוך רשימה של מספרים:

def has_number(numbers, number):
    for num in numbers:
        if num == numbers:
            return True
        
    return False
numbers = range(100)
has_number(numbers, 101)

התוצאה:

False
  • בתוך הפונקציה יש לולאה אשר רצה עד שהיא מוצאת את המספר, ואם היא מוצאת היא מחזירה True.
  • במידה והמספר לא קיים במערך, הלולאה מסיימת לרוץ על כל הפריטים ואחרי שהיא מסיימת היא מחזירה False.

במקרה הטוב הלולאה תרוץ פעם אחת, תמצא התאמה כבר בפריט הראשון ותחזיר True. במקרה הגרוע, הלולאה תרוץ על כל המערך ורק אז תחזיר False.

בשביל big-O מה שמעניין הוא בדרך כלל הוא המקרה הגרוע.

במקרה שלנו, משך הריצה של הפונקציה מתארך באופן קווי ככל שהפונקציה מקבלת רשימה ארוכה יותר כי מה שמעניין אותנו הוא המקרה הגרוע ביותר, שבו הפונקציה רצה את כל הרשימה עד סופה.

כדי לבחון את התארכות משך הריצה של הפונקציה נעביר רשימות הולכות ומתארכות של אפסים (10 אפסים, 100, אלף וכיו"ב) ונבחן את התארכות משך הריצה.

נייבא את הספריות:

import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

נריץ את הפונקציה על רשימות של אפסים שאורכם נע בין 10 למיליון:

ranges = [10**e for e in range(1,7)]
deltas = []
for n in ranges:
    numbers = [0]*n
    start = time.process_time()
    has_number(numbers, -1)
    end = time.process_time()
    delta = end-start
    deltas.append(delta)
  • הפונקציה בודקת אם המספר 1 קיים ברשימה המורכבת כולה מאפסים כי המקרה הגרוע ביותר הוא כאשר מספר לא קיים ברשימה, ומה שמעניין אותנו הוא המקרה הגרוע ביותר.
  • אספנו את משך הזמן שהפונקציה רצה לרשימה deltas.

נציג בטבלה את משך הריצה כתלות ב-N:

df = pd.DataFrame(list(zip(ranges, deltas)), columns=['n', 'time'])
df

the execution time of the function grows linearly with the number of items in the list

נציג את משך הריצה כתלות ב-N באמצעות תרשים שתייצר בשבילנו הפונקציה:

def plot_graph(df, title, ylim_to=0.1):
    plt.figure(figsize=(9, 6))
    plt.plot(df['n'], df['time'])
    plt.xlabel('N', fontsize=20)
    plt.ylabel('time(s)', fontsize=18)
    plt.ylim([0, ylim_to])
    plt.title(title, fontsize=24)

התוצאה:

plot_graph(df, "O(N)", 0.03)

the execution time of the function grows linearly with the number of items in the list

  • רואים עלייה ליניארית בזמן הריצה של הפונקציה בהתאם להתארכות הרשימה.

אלגוריתם ליניארי אינו יעיל במיוחד כיוון שמשך הריצה שלו גדל בהתאם לכמות המידע שהוא מקבל.

 

 

קצב גידול לוגריתמי O(logN)

בחלק הקודם ראינו פונקציה שמחפשת מספר בתוך רשימה של מספרים. היא מתחילה מהפריט הראשון ברשימה ורצה כמה שצריך עד למציאת התאמה. מה שגורם לתלות ליניארית בין כמות הפריטים ומשך הריצה. הבעיה עם אלגוריתם ליניארי שהוא אינו יעיל במיוחד. הפתרון יכול להיות פונקציה לוגריתמית.

אלגוריתם חיפוש יעיל במיוחד משתמש בחיפוש בינארי binary search. הוא כולל את השלבים הבאים:

  1. מקבל רשימה מסודרת. לדוגמה, מספרים מסודרים בסדר עולה.
  2. חוצה את הרשימה לשניים ובודק האם הפריט באמצע הרשימה הוא המספר המבוקש.
  3. אם הפריט האמצעי הוא גדול מהמספר שמחפשים עובר לחפש ברשימה השמאלית. אם קטן יותר, יחפש בימנית.
  4. בשביל לחפש בתת-הרשימה הוא יחצה גם אותה לשניים, וחוזר חלילה עד למציאת הפריט המבוקש.

האלגוריתם binary search ממשיך לחצות את הרשימה לחצאים שאורכם הולך וקצר עד למציאת הערך המבוקש.

בכל פעם שאנחנו נתקלים באלגוריתם שמחלק רשימות לשניים אנחנו יכולים לחשוד שה big-O שלו הוא לוגריתמי.

נכתוב את קוד האלגוריתם binary search:

def binary_search(array, x):
    low = 0
    high = len(array) - 1
    
    while low <= high:
        # find the median
        mid = (high + low)//2
        
        # if the median is the value you
        # search for return true
        if array[mid] == x:
            return True
        
        # if the value is smaller than the median
        # search in the lower half of the list
        elif x < array[mid]:
            high = mid - 1
        # otherwise search in the upper half
        else:
            low = mid + 1

    return False

קוד שמחלק את הבעיה לחצי בכל פעם שמריצים אותו גורם לקשר לוגריתמי בין כמות המידע להתארכות משך זמן הריצה. כי הכפלת כמות המידע פי-2 גורמת להוספת סיבוב 1 לריצה. לדוגמה, בשביל רשימה באורך 2 פריטים הלולאה תרוץ לכל היותר פעמיים. עבור 4 פריטים - לכל היותר 3. עבור 8 פריטים לכל היותר 4. וכיו"ב. זה גידול לוגריתמי המתואר באמצעות ביטוי: O(log N).

זו תוצאה של ניסוי שמראה את התארכות משך זמן החיפוש של האלגוריתם ככל שגדל מספר הפריטים:

the execution time grows logarithmically with the number of items in the list, a big-o of log N

התהליך אשר חוצה את הרשימה בכל פעם לחצי גורם לקצב התארכות מהיר יותר של זמן הביצוע עבור רשימות קצרות אבל העקום מציג התמתנות בקצב הגידול ככל שמספר הפריטים גדל.

התרשים הבא משווה את ביצועי האלגוריתם הלוגריתמי מסוג O(log N) כנגד אלגוריתם בעל קצב גדילה ליניארי O(N):

the execution time of logarithmic big-o log n code is better at scale than linearic algorithm

  • אפילו במספרים נמוכים של כמה מאות ואלפים אפשר לראות את האלגוריתם הלוגריתמי מפגין ביצועים טובים בהרבה מאשר הליניארי.

אלגוריתמים לוגריתמיים המתוארים כ-O(log N) הם יעילים ביותר עבור כמות גדולה של נתונים. מוטב להשתמש בהם כמה שיותר.

גידול פולינומי במשך זמן הריצה O(N^2)

קוד שמשך הריצה שלו מתארך בהתאם לריבוע כמות המידע שהוא מקבל מכונה O(N^2).

סיבה עיקרית לגידול פולינומי המתואר באמצעות O(N^2) הינו קוד אשר מריץ לולאה מקוננת. לולאה בתוך לולאה.

לדוגמה, פונקציה המקבלת רשימת מספרים, וכופלת כל מספר ברשימה בכל המספרים ברשימה, ולשם כך היא נעזרת בשתי לולאות מקוננות:

def multi_num(numbers):
    res = []
    for i in numbers:
        for j in numbers:
            res.append(i * j)
    return res

נריץ אותה על רשימה של מספרים בין 0 ל-9:

multi_num(range(10))
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 0, 3, 6, 9, 12, 15, 18, 21, 24, 27, 0, 4, 8, 12, 16, 20, 24, 28, 32, 36, 0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 0, 6, 12, 18, 24, 30, 36, 42, 48, 54, 0, 7, 14, 21, 28, 35, 42, 49, 56, 63, 0, 8, 16, 24, 32, 40, 48, 56, 64, 72, 0, 9, 18, 27, 36, 45, 54, 63, 72, 81]

כדי לבחון את התמשכות הריצה ככל שמזינים יותר נתונים הרצתי את הפונקציה על רשימות באורכים שונים:

ranges = [2**e for e in range(3,14)]
deltas = []
for r in ranges:
    numbers = [n for n in range(r)]
    start = time.process_time()
    multi_num(numbers)
    end = time.process_time()
    delta = end-start
    deltas.append(delta)

התוצאות מתוארות באמצעות הגרף הבא:

the execution time grows polynomially with the number of items in the list, a big-o of n^2

לא ניסיתי להריץ על רשימות ארוכות מ-8,192 פריטים בגלל שמשך הריצה קופץ מהר מאוד כשעובדים עם אלגוריתמים פולינומים מסוג O(N^2).

את התמשכות זמן הריצה של פונקציה מקוננת נתאר באמצעות O(N^2). באופן דומה, זמן ריצה של 3 לולאות מקוננות יתואר באמצעות big-O של O(N^3). של 4 לולאות באמצעות O(N^4). וכיו"ב. הקטסטרופה רק מעצימה ככל שמקננים יותר לולאות.

התרשים הבא משווה את ביצועי האלגוריתם הפולינומי מסוג O(N^2) כנגד אלגוריתם בעל קצב גדילה ליניארי O(N) ולוגריתמי O(logN):

the execution time of polynomial algorithm does not scale

  • אפילו במספרים נמוכים של כמה מאות ואלפים אפשר לראות את ההדרדרות ביכולת הביצוע של האלגוריתם הפולינומי O(N^2) שהוא הרבה יותר גרוע אפילו מאלגוריתם ליניארי O(N).

העובדה שפונקציות שכתובות בצורה לא נכונות יכולות לצאת משליטה הם סיבה שבגללה צריך להיות מודע ל-big-O. מה שראינו גם צריך ללמד אותנו שצריך להימנע מלולאות מקוננות (לולאה בתוך לולאה) כמה שאפשר בקוד שלנו.

העובדה שפונקציות שכתובות בצורה לא נכונות יכולות לצאת משליטה הם סיבה שבגללה צריך להיות מודע ל-big-O.

 

קצב גידול אקספוננציאלי הידוע בכינויו "תמצא אלגוריתם יעיל יותר"

אם משך הריצה של הקוד שלך גדל באופן אקספוננציאלי אז אתה בבעיה.

לדוגמה, הקוד הבא מוצא את סדרת פיבונאצ'י עבור המספרים שמעבירים אליו:

def fibonaci(number):
    if number <= 1:
        return number
    return fibonaci(number-1)+fibonaci(number-2)

כל מספר בסדרת פיבונאצ'י הוא הסכום של שני המספרים שקדמו לו. הסדרה מתחילה מ-0.

0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144

מה המספר החמישי בסדרה?

fibonaci(4)
  • העברנו 4 כפרמטר כי מחשבים מתחילים לספור מ-0.

התוצאה:

3

בחנתי את התארכות משך הריצה של הרצת הפונקציה כנגד סדרה של מספרים מ-2 עד 34 (כי מעבר לזה המחשב שלי קרס), וזו התוצאה:

the execution time grows exponentially with the number of items in the list, a big-o of 2^n

זמן הריצה גדל באופן אקספוננציאלי - הוא מתחיל מאוד רדוד, ואז עולה כמו מטאור. הדרך לתאר את זה היא באמצעות ביטוי big-O שהוא O(2^N), ואם זה הביטוי שמתאר את הקוד שלך אז תכתוב אותו מחדש.

 

אלגוריתמים גרועים עוד יותר מתאפיינים בקצב גידול פקטוריאלי O(N!) בגלל שהם מחשבים את כל הקומבינציות של רשימה. ואם אמרנו שקצב גידול אקספוננציאלי הוא משהו שצריך להימנע ממנו אז קצב פקטוריאלי הוא גרוע אפילו יותר.

 

חישוב big-O עבור מקרים מורכבים

עד כה ראינו פונקציות שיש בהם פעולה אחת אבל מה קורה כשיש יותר.

לדוגמה, פונקציה שמציבה 1 לפריט הראשון ברשימה:

def assign_one(numbers):
    list[0] = 1

הפונקציה שייכת לסוג O(1) בגלל שמשך הריצה שלה נותר קבוע ללא תלות במספר הפריטים שמזינים לתוכה.

אבל מה לגבי פונקציה שמציבה 1 לפריט הראשון וגם לפריט השני?

def assign_ones(numbers):
    list[0] = 1
    list[1] = 1

במקום פעולה 1 הפונקציה מבצעת 2.

במקרה שהפונקציה מבצעת מספר פעולות נפרק אותה לפעולות פרטניות, ונחשב עבור כל אחת מהם את ה-big-O:

  • הפעולה הראשונה היא O(1)
  • גם הפעולה השנייה היא O(1)

סכום ה-big-O של שתי הפעולות הוא 2.

אותנו מעניין ה-N ומהקבוע (2 במקרה זה) אנחנו יכולים להתעלם.

לכן ה-big-O הוא O(1)

למה אנחנו יכולים להתעלם מהקבוע? כי המטרה של big-O היא להבין איך הקוד יתמודד עם רשימות ארוכות במיוחד. וכאשר הרשימה מאוד ארוכה ההבדל בין N לבין 2N הוא זניח. אפשר לחשוב על זה שאין הבדל בין אינסוף לבין פעמיים אינסוף בכל מקרה מדובר במספר מאוד גדול.

 

נראה דוגמה נוספת לפונקציה מורכבת:

 def do_stuff(array):
     array[0] = 1

     array_len = len(array)

     sum = 0
     for item in array:
          sum += item

     for  item in array:
         sum += item

    return sum

הפונקציה מורכבת מארבע פעולות. נמצא את ה-big-O של כל אחת מהן:

  • הצבה של 1 לפריט הראשון של הרשימה - O(1)
  • חישוב האורך של רשימה - O(1)
  • סכימה של כל פריטי הרשימה בתוך לולאה - O(N)
  • סכימה בתוך לולאה פעם שנייה - O(N)

נסכום את ה big-O של הפעולות:

1 + 1 + N + N

אפשר לפשט את הביטוי:

2 * 1 + 2 * N

נסיר את הקבועים כי עבור כמות נתונים מאוד גדולה הם לא משנים:

1 + N

נמצא את הביטוי בעל הסדר הגבוה, ונזניח את כל היתר, כי עבור כמות נתונים גדולה תרומת הפעולות מסדר הגודל הנמוך הופכת זניחה:

N

נשתמש בביטוי בעל הסדר הגבוה בתור ה- big-O:

O(N)

 

את הפעולות שעשינו כדי למצוא את big-O ניתן לסכם באמצעות אלגוריתם הכולל 5 צעדים:

  1. נחלק את הפונקציה למספר פעולות.
  2. נמצא את ה big-O של כל פעולה.
  3. נסכום את ה big-O של הפעולות השונות.
  4. נסיר את הקבועים.
  5. נשאיר את הביטוי שהסדר שלו הוא הגבוה ביותר, ונזנח את כל היתר.

 

סיכום

במדריך זה ראינו מספר אופנים נפוצים לתיאור התארכות משך הזמן של ביצוע משימה ביחס לגידול כמות המידע בו מזינים את התהליך המתוארים באמצעות big-O. הבנת הנושא ויישומו יכולה להיות כל ההבדל בין פונקציה שמשך הריצה שלה סביר ובין קוד אשר גורם לקריסת מערכות. עבור כמות גדולה של נתונים נעדיף אלגוריתם לוגריתמי, ומצד שני נימנע מפונקציות פולינימיות או אקספוננציאליות.

להורדת הקוד עליו מבוסס מדריך big-O

לכל המדריכים בסדרה ללימוד פייתון

 

אהבתם? לא אהבתם? דרגו!

0 הצבעות, ממוצע 0 מתוך 5 כוכבים

 

 

הוסף תגובה חדשה

 

= 7 + 3