Час повернутися у часі до уроку. Хоча ми сьогодні не дуже багато думаємо про ці речі в наших фантазійних керованих мовах, вони побудовані на одній основі, тому давайте подивимось, як керується пам’яттю в C.
Перш ніж зануритися, коротке пояснення того, що означає термін « вказівник ». Вказівник - це просто змінна, яка "вказує" на місце в пам'яті. Він не містить фактичного значення в цій області пам'яті, він містить адресу пам'яті до нього. Подумайте про блок пам'яті як про поштову скриньку. Вказівник буде адресою цієї поштової скриньки.
У C масив - це просто вказівник зі зміщенням, зміщення вказує, наскільки в пам'яті слід шукати. Це забезпечує час доступу O (1) .
MyArray [5]
^ ^
Pointer Offset
Усі інші структури даних або надбудовуються на цьому, або не використовують суміжну пам'ять для зберігання, що призводить до поганого часу пошуку випадкового доступу (Хоча є й інші переваги, якщо не використовувати послідовну пам'ять).
Наприклад, скажімо, у нас є масив з 6 числами (6,4,2,3,1,5), в пам'яті він виглядатиме так:
=====================================
| 6 | 4 | 2 | 3 | 1 | 5 |
=====================================
У масиві ми знаємо, що кожен елемент знаходиться поруч один з одним у пам'яті. AC масив (називається MyArray
тут) - це просто вказівник на перший елемент:
=====================================
| 6 | 4 | 2 | 3 | 1 | 5 |
=====================================
^
MyArray
Якби ми хотіли подивитися MyArray[4]
, всередині нього можна було б отримати доступ до цього:
0 1 2 3 4
=====================================
| 6 | 4 | 2 | 3 | 1 | 5 |
=====================================
^
MyArray + 4 ---------------/
(Pointer + Offset)
Оскільки ми можемо безпосередньо отримати доступ до будь-якого елемента в масиві, додавши зміщення до покажчика, ми можемо шукати будь-який елемент за однаковий час, незалежно від розміру масиву. Це означає, що отримання часу MyArray[1000]
займає стільки ж часу, як і отримання MyArray[5]
.
Альтернативна структура даних - пов'язаний список. Це лінійний список покажчиків, кожен із яких вказує на наступний вузол
======== ======== ======== ======== ========
| Data | | Data | | Data | | Data | | Data |
| | -> | | -> | | -> | | -> | |
| P1 | | P2 | | P3 | | P4 | | P5 |
======== ======== ======== ======== ========
P(X) stands for Pointer to next node.
Зауважте, що я зробив кожен "вузол" у свій блок. Це тому, що вони не гарантують, що (і, швидше за все, не будуть) сусідніми в пам'яті.
Якщо я хочу отримати доступ до P3, я не можу отримати прямий доступ до нього, бо не знаю, де він знаходиться в пам'яті. Все, що я знаю, - де знаходиться корінь (P1), тому замість цього я повинен почати з P1 і слідувати за кожним вказівником на потрібний вузол.
Це час пошуку О (N) (вартість пошуку збільшується із додаванням кожного елемента). Дістатися до P1000 набагато дорожче порівняно з доїздом до P4.
Структури даних вищого рівня, такі як хештелі, стеки та черги, усі можуть використовувати масив (або декілька масивів) внутрішньо, тоді як пов'язані списки та двійкові дерева зазвичай використовують вузли та покажчики.
Вам може бути цікаво, чому хтось використовуватиме структуру даних, яка потребує лінійного обходу, щоб шукати значення, а не просто використовувати масив, але вони мають своє використання.
Знову візьміть наш масив. Цього разу я хочу знайти елемент масиву, який містить значення '5'.
=====================================
| 6 | 4 | 2 | 3 | 1 | 5 |
=====================================
^ ^ ^ ^ ^ FOUND!
У цій ситуації я не знаю, який зсув додати до вказівника, щоб знайти його, тому я повинен почати з 0 і працювати вгору, поки не знайду його. Це означає, що я повинен виконати 6 перевірок.
Через це пошук значення в масиві вважається O (N). Вартість пошуку збільшується в міру збільшення масиву.
Згадайте вище, де я говорив, що іноді використання непослідовної структури даних може мати переваги? Пошук даних є однією з цих переваг, і один з найкращих прикладів - Бінарне дерево.
Бінарне дерево - це структура даних, схожа на зв'язаний список, однак замість посилання на один вузол, кожен вузол може посилатися на два дочірні вузли.
==========
| Root |
==========
/ \
========= =========
| Child | | Child |
========= =========
/ \
========= =========
| Child | | Child |
========= =========
Assume that each connector is really a Pointer
Коли дані вставляються у двійкове дерево, воно використовує кілька правил, щоб вирішити, де розмістити новий вузол. Основна концепція полягає в тому, що якщо нове значення більше, ніж у батьків, воно вставляє його зліва, якщо воно нижче, воно вставляє його праворуч.
Це означає, що значення у двійковому дереві можуть виглядати так:
==========
| 100 |
==========
/ \
========= =========
| 200 | | 50 |
========= =========
/ \
========= =========
| 75 | | 25 |
========= =========
Під час пошуку бінарного дерева на значення 75 нам потрібно відвідати лише 3 вузли (O (log N)) через цю структуру:
- На 75 менше 100? Подивіться на Правий вузол
- 75 більше 50? Подивіться на Лівий вузол
- Є 75!
Незважаючи на те, що на нашому дереві є 5 вузлів, нам не потрібно було дивитись на два інших, оскільки ми знали, що вони (та їхні діти) не можуть містити значення, яке ми шукали. Це дає нам час пошуку, що в гіршому випадку означає, що ми повинні відвідувати кожен вузол, але в кращому випадку нам потрібно лише відвідати невелику частину вузлів.
Ось де масиви перебиваються, вони забезпечують лінійний час пошуку O (N), незважаючи на час доступу O (1).
Це неймовірно високий огляд структур даних в пам'яті, пропускаючи багато деталей, але, сподіваємось, він ілюструє силу та слабкість масиву порівняно з іншими структурами даних.