CodeGym /Kursy /Go SELF /Usuwanie elementu z slice: stable vs unstable remove

Usuwanie elementu z slice: stable vs unstable remove

Go SELF
Poziom 13, Lekcja 3
Dostępny

1. Co tak naprawdę znaczy „usunąć element” z slice

Gdy początkujący słyszy „usunąć z tablicy”, wyobraża sobie obrazek jak w Excelu: wiersz zaznaczony, Delete — i świat stał się lepszy. W Go wszystko jest bardziej pragmatyczne: slice to „okienko” na tablicę i nie ma w nim wbudowanej operacji „wyrwać element z mięsem”. Usuwanie to staranne połączenie przesunięcia i zmiany len, czasem z dodatkowym sprzątaniem ogona.

Prawie każde usunięcie z []T rozkłada się na trzy kroki:

  1. zamknąć dziurę (zwykle przez przesunięcie elementów),
  2. zmniejszyć długość (reslice),
  3. w razie potrzeby wyczyścić ogon (żeby nie zostawiać śmieci i nie trzymać pamięci).

Schematycznie można to przedstawić tak:

Było:   [A B C D E]  len=5
Usuwamy C (i=2)

Przesunięcie: [A B D E E]  len=5   (dziura zamknięta, ale ogon jest "zduplikowany")
Reslice:      [A B D E]    len=4
Clear:        [A B D E _]  (jeśli chcemy wyzerować zwolniony slot)

Wygląda to trochę „niskopoziomowo”, ale daje pełną kontrolę: dokładnie wiemy, co stało się z danymi i dlaczego.

2. Stable remove: usuwanie z zachowaniem kolejności

Stable remove to usuwanie „po ludzku”: jeśli elementy szły w jakiejś kolejności, to po usunięciu nadal idą w tej samej kolejności, tylko bez jednego elementu. Taki sposób idealnie pasuje do list zadań, historii działań, kolejek wyświetlania w UI i w ogóle wszystkiego, gdzie kolejność ma znaczenie. Cena jest taka, że kopiujemy (przesuwamy) kawałek ogona, a więc wykonujemy trochę więcej pracy.

Mechanika stable remove przez copy

Najprostszy przepis wygląda tak: jeśli usuwamy element o indeksie i, to kopiujemy ogon s[i+1:] na miejsce s[i:].

package main

import "fmt"

func main() {
	s := []int{10, 20, 30, 40}
	i := 1

	copy(s[i:], s[i+1:])
	fmt.Println(s) // [10 30 40 40]
}

Zwróć uwagę na „duplikat” na końcu. To normalne: copy nadpisało elementy, ale nie zmieniło długości, więc ostatni element pozostał „jaki był” (albo stał się kopią przedostatniego). Aby zakończyć usuwanie, musimy zmniejszyć len.

Kończymy usuwanie: zmniejszamy len i czyścimy ogon

Teraz złożymy usuwanie w funkcję. Ważne: skoro długość się zmienia, funkcja musi zwracać nowy slice, a kod wywołujący musi ten wynik zapisać.

package main

import "fmt"

func removeAtStable(s []string, i int) []string {
	if i < 0 || i >= len(s) {
		return s
	}
	copy(s[i:], s[i+1:])
	clear(s[len(s)-1:]) // sprzątanie ogona
	return s[:len(s)-1]
}

func main() {
	tasks := []string{"read", "code", "sleep"}
	fmt.Println(removeAtStable(tasks, 1)) // [read sleep]
}

Tutaj clear(s[len(s)-1:]) czyści zwolniony slot. Dla string jest to szczególnie przydatne jako dyscyplina: jawnie mówisz „ten element nie jest już potrzebny”, a ogon nie trzyma starego odwołania do danych tekstowych.

3. Unstable remove: usuwanie bez zachowania kolejności

Unstable remove to sposób dla tych sytuacji, gdy kolejność elementów nie ma znaczenia, a szybkość i prostota pracy z pamięcią są ważniejsze. Pomysł jest bardzo prosty, a nawet trochę bezczelny: „skoro kolejność nie ma znaczenia, to połóżmy na miejsce usuwanego elementu ostatni element, a długość zmniejszmy”. Ogon przy tym prawie się nie przesuwa, więc pracy zwykle jest mniej.

Wyobraź sobie, że masz listę tymczasowych ID, zestaw aktywnych połączeń, pulę obiektów, kolekcję „kandydatów”, gdzie kolejność nie wpływa na wynik. Tam unstable remove może być bardzo przydatny.

Mechanika unstable remove (swap-with-last)

Oto minimalna logika:

package main

import "fmt"

func main() {
	s := []int{10, 20, 30, 40}
	i := 1
	last := len(s) - 1

	s[i] = s[last]
	s = s[:last]

	fmt.Println(s) // [10 40 30]
}

Usunęliśmy „20”, ale „40” przeskoczyło na jego miejsce. Kolejność się zmieniła — i właśnie to jest „unstable”.

Funkcja unstable remove z czyszczeniem ogona

Zróbmy staranną funkcję, która, podobnie jak wariant stable, sprawdza granice, zwraca nowy slice i czyści ostatni slot.

package main

import "fmt"

func removeAtUnstable(s []string, i int) []string {
	n := len(s)
	if i < 0 || i >= n {
		return s
	}
	s[i] = s[n-1]
	clear(s[n-1:]) // żeby nie trzymać odwołania w ogonie
	return s[:n-1]
}

func main() {
	tasks := []string{"read", "code", "sleep"}
	fmt.Println(removeAtUnstable(tasks, 1)) // [read sleep] (kolejność mogła się zmienić)
}

W tym konkretnym przykładzie wynik może wyglądać tak samo jak w stable, bo element „sleep” i tak był ostatni. Ale gdybyśmy usuwali indeks 0, zobaczylibyśmy przestawienie.

4. Stable vs unstable: jak wybrać strategię

Gdy wybierasz strategię usuwania, tak naprawdę wybierasz „jaką cenę płacę za zachowanie kolejności”. Stable remove płaci kopiowaniem ogona, unstable — płaci tym, że kolejność się psuje.

Mała tabela, aby utrwalić decyzję:

Kryterium Stable remove Unstable remove
Kolejność elementów zachowana niezachowana
Ile elementów „przesuwamy” zwykle dużo (ogon) zwykle 1 (ostatni)
Typowe przypadki listy dla użytkownika, historia, uporządkowane dane „worek” elementów, gdzie kolejność nie ma znaczenia
Ryzyko niespodzianek mniejsze większe (kolejność się zmienia)

I jeszcze jedna ważna myśl: stable remove jest prostszy dla mózgu, bo odpowiada oczekiwaniom człowieka. Unstable remove jest prostszy dla komputera. W życiu często wygrywa mózg, ale czasem naprawdę potrzebujesz „worka”, i wtedy unstable remove jest uczciwy i szybki.

Krótkie „oficjalne” wzorce usuwania przez append

Jest popularna jednowierszowa forma usuwania elementu, którą często zobaczysz w cudzym kodzie:

s = append(s[:i], s[i+1:]...)

W praktyce jest to również stable remove: bierzemy „głowę” s[:i] i „ogon” s[i+1:], a następnie sklejamy je przez append. Takie podejście jest rzeczywiście uważane za klasyczne i szeroko stosowane jako standardowy przepis na usuwanie zakresu z slice.

Ale ważne jest zrozumienie dwóch rzeczy, żeby nie zamienić tego przepisu w magiczne zaklęcie.

Po pierwsze, jest to operacja zmieniająca długość, więc wynik trzeba koniecznie przypisać z powrotem do s. Po drugie, pod maską i tak następuje kopiowanie elementów ogona, po prostu wyrażasz to „w jednej linijce”, a nie w dwóch krokach copy + reslice. Gdy piszesz kod do nauki albo debugowania, wariant copy + s[:newLen] bywa łatwiejszy do czytania i komentowania.

5. Miniaplikacja „Lista zadań” i usuwanie zadań

Teraz znowu złożymy małą konsolową „aplikację”, która opiera się tylko na tym, co już znasz: slice, funkcjach, fmt, copy, clear. Niech to będzie prosty model: lista zadań to []string, a usuwanie zadania to usuwanie po indeksie. Bez struktur i plików, tylko uczciwa praca ze slice.

Wypisywanie zadań z czytelną numeracją

Użytkownik zwykle liczy od 1, a indeksy w Go zaczynają się od 0. To klasyczne źródło bólu, więc zrobimy funkcję wypisywania, która pokazuje numer „po ludzku”.

package main

import "fmt"

func printTasks(tasks []string) {
	for i, t := range tasks {
		fmt.Printf("%d) %s\n", i+1, t)
	}
}

func main() {
	printTasks([]string{"read", "code", "sleep"})
	// 1) read
	// 2) code
	// 3) sleep
}

Usuwanie zadania: wariant stable

Teraz dodajmy stable remove w kontekście „zadań”. Tutaj kolejność prawie zawsze ma znaczenie: jeśli napisałeś listę rzeczy do zrobienia, dziwnie by było, gdyby po usunięciu drugiego punktu nagle trzeci zamienił się miejscami z ostatnim (psychologicznie odczuwa się to jak „aplikacja się zepsuła”).

package main

import "fmt"

func removeTaskStable(tasks []string, humanNumber int) []string {
	i := humanNumber - 1
	if i < 0 || i >= len(tasks) {
		return tasks
	}
	copy(tasks[i:], tasks[i+1:])
	clear(tasks[len(tasks)-1:])
	return tasks[:len(tasks)-1]
}

func main() {
	tasks := []string{"read", "code", "sleep"}
	tasks = removeTaskStable(tasks, 2)
	fmt.Println(tasks) // [read sleep]
}

Zwróć uwagę: przyjmujemy humanNumber, a wewnątrz od razu zamieniamy go na indeks. Zmniejsza to ryzyko off-by-one, bo konwersja jest w jednym miejscu i jest oczywista.

Usuwanie zadania: wariant unstable

A teraz wyobraźmy sobie inną sytuację: mamy listę „tymczasowych znaczników”, „subskrypcji” albo „aktywnych id”, a kolejność w ogóle nie ma znaczenia. Chcemy szybko usunąć element i nie przesuwać ogona.

package main

import "fmt"

func removeTaskUnstable(tasks []string, humanNumber int) []string {
	i := humanNumber - 1
	n := len(tasks)
	if i < 0 || i >= n {
		return tasks
	}
	tasks[i] = tasks[n-1]
	clear(tasks[n-1:])
	return tasks[:n-1]
}

func main() {
	tasks := []string{"A", "B", "C", "D"}
	tasks = removeTaskUnstable(tasks, 2)
	fmt.Println(tasks) // na przykład: [A D C]
}

Tutaj ważne jest uczciwe komentowanie sensu: „kolejność się zmienia”. Unstable remove jest dobry wtedy, gdy jest to oczekiwane i nie wprowadza człowieka w konsternację.

6. Ogon i pamięć: dlaczego clear obok usuwania jest przydatny

Gdy dopiero zaczynasz, clear obok usuwania wygląda jak „zbędne sprzątanie”. W końcu i tak zmniejszyliśmy len, kto w ogóle zobaczy ogon? Problem w tym, że ogon może być niewidoczny, ale nadal wpływać na pamięć: dopóki w backing array pozostaje odwołanie do danych, garbage collector może uznać te dane za osiągalne i ich nie zwolnić.

Ta kwestia nie została wymyślona do podręcznika. Nawet autorzy standardowych funkcji musieli podejmować decyzje, czy czyścić „przestarzałe elementy” po usunięciu, bo inaczej użytkownicy przypadkiem zatrzymywali pamięć przez ogon. W rezultacie niektóre standardowe operacje w nowszych wersjach Go zaczęły wyraźnie „czyścić ogon” przez clear, aby zachowanie było mniej zaskakujące.

Dlatego w przykładach dydaktycznych wyrabiamy sobie nawyk: jeśli zmniejszyłeś len, zastanów się, czy trzeba wyzerować zwolnione elementy. Dla []int to częściej kwestia staranności. Dla []string — już kwestia higieny pamięci. Dla slice zawierających „ciężkie” referencje (na przykład duże stringi) może to stać się zauważalne.

Indeksy: mini schemat blokowy usuwania

Usuwanie często psuje się nie przez copy, lecz przez indeksy. Dlatego warto trzymać przed oczami prosty „mentalny schemat blokowy”, zwłaszcza gdy piszesz swój pierwszy kod bez podpowiedzi.

flowchart TD
    A[Masz slice s i indeks i] --> B{"0 <= i < len(s)?"}
    B -- nie --> C[Nic nie robimy, zwracamy s]
    B -- tak --> D["Stable: copy(s[i:], s[i+1:])"]
    D --> E["clear(s[len-1:]) w razie potrzeby"]
    E --> F["reslice: s = s[:len-1]"]
    F --> G[Zwracamy nowy s]

Jeśli wybierasz unstable remove, środek zmienia się na „s[i] = s[len-1]”, a potem wszystko pozostaje takie samo: (opcjonalnie) czyścimy ogon i zmniejszamy len.

8. Typowe błędy przy usuwaniu elementu z slice

Błąd №1: zrobili copy, ale zapomnieli zmniejszyć len.
To bardzo częsta sytuacja: przesunąłeś ogon, spojrzałeś na slice, zobaczyłeś „prawie dobrze”, a na końcu wystaje zbędny zduplikowany element. Leczy się to prosto: pamiętaj, że copy nigdy nie zmienia len, a końcowy krok usuwania to s = s[:len(s)-1].

Błąd №2: zapomnieli zapisać wynik funkcji usuwającej.
Jeśli napisałeś tasks = removeTaskStable(tasks, 2) — wszystko dobrze. Jeśli napisałeś po prostu removeTaskStable(tasks, 2), to wewnątrz funkcja mogła zmienić backing array, ale długość w zmiennej tasks pozostanie taka sama. Powstaje dziwna półdziałająca sytuacja: elementy jakby się przesunęły, ale lista nie stała się krótsza.

Błąd №3: pomylili „numer dla człowieka” z indeksem.
Użytkownik mówi „usuń zadanie numer 1”, a ty usuwasz tasks[1], czyli drugie. To ten sam off-by-one, który prześladuje programistów jak urząd skarbowy — czasem nagle. Najlepsza profilaktyka: w jednym miejscu zamienić humanNumber na i := humanNumber - 1 i dalej wszędzie używać tylko i.

Błąd №4: używają unstable remove tam, gdzie kolejność jest ważna.
Unstable remove może być świetny, ale jeśli zastosujesz go do „listy zadań”, użytkownik zobaczy, że elementy przeskakują miejscami, i dojdzie do wniosku: „program się psuje”. To nie błąd algorytmu, tylko błąd UX. Jeśli kolejność ma znaczenie — bierz stable remove, nawet jeśli kopiuje więcej.

Błąd №5: nie wyczyścili ogona, a potem dziwią się pamięci albo „wyskakującym” wartościom.
Nawet jeśli nie planujesz robić reslice z powrotem do cap, ogon nadal pozostaje częścią backing array. Dla typów referencyjnych może to zatrzymywać pamięć. Nawyk clear(s[newLen:oldLen]) (albo przynajmniej clear(s[len(s)-1:]) przy usuwaniu jednego elementu) sprawia, że zachowanie jest bardziej uczciwe i przewidywalne, zwłaszcza gdy dane są „ciężkie”.

1
Zadanie
Go SELF, poziom 13, lekcja 3
Niedostępne
Usuń bez hałasu
Usuń bez hałasu
1
Zadanie
Go SELF, poziom 13, lekcja 3
Niedostępne
Tryb usuwania
Tryb usuwania
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION