JavaRush /Courses /Go SELF /Inserting an element and “filtering”

Inserting an element and “filtering”

Go SELF
Level 13, Lesson 4
Available

1. Inserting a single element

If you’ve ever manually edited a list in a text editor, you already know the problem: inserting a line in the middle means shifting everything down, and deleting “unnecessary” lines means carefully “compressing” the list so no gaps remain. With slices it’s exactly the same story, except instead of a notepad we have a backing array, and instead of an eraser we have copy, append, and sometimes clear.

Let’s do everything in one small app that we’ll evolve: a mini manager for numeric “tasks”. Suppose we have a list of integers—for example, “task priorities” or “importance scores”. We want to be able to insert a new element at an arbitrary position and filter elements by a condition (for example, keep only values >= min).

When you insert an element into the middle of a slice, you need to do two things: first find the spot, and then not lose the tail. In Go, the “space” (via len) usually appears through append, and the tail is shifted with copy. It’s like making room in a line: first the line must become “longer” (space appears), and then you carefully shift the people on the right so you can squeeze in.

Key idea: copy can work with overlapping ranges. That means a “shift to the right” within the same backing array is a normal operation.

The insertion algorithm in words

Let s = [10, 20, 30, 40], and we want to insert 99 at position i = 2:

  1. increase the length by 1 (append any “empty” element to the end)
  2. shift the tail to the right: elements from i to the end move to i+1
  3. write 99 into s[i]

So this doesn’t remain “poetry”, let’s write a function.

package main

import "fmt"

func insertAt(s []int, i int, v int) []int {
	if i < 0 || i > len(s) {
		return s // bad index — do nothing
	}

	s = append(s, 0)      // 1) increased len by 1
	copy(s[i+1:], s[i:])  // 2) shift the tail to the right
	s[i] = v              // 3) write the new value
	return s
}

func main() {
	s := []int{10, 20, 30, 40}
	s = insertAt(s, 2, 99)

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

Note the careful condition i > len(s) (not >=). Inserting at position len(s) is a legal operation: it’s just appending to the end—“no need to spread the line, just stand last”.

Why append first, then copy

Beginners often try to “shift the tail” in the old slice before increasing the length. And that’s logical: it feels like copy will somehow “expand” the slice. But copy does not change len—it only overwrites existing elements. So if you try to shift right without extending the length, you either get a panic because of invalid slice bounds, or you silently overwrite the wrong data.

Compare these two thoughts:

  • “I want the elements to move to the right” — that’s about data.
  • “I need space on the right” — that’s about len.

In Go, “space in terms of length” is created with append (or with reslicing after pre-growing the buffer, but for us right now—it’s append).

Let’s look at a small “anti-example” (it doesn’t always compile and often crashes, so treat it like a warning sign, not advice).

package main

import "fmt"

func main() {
	s := []int{1, 2, 3}
	i := 1

	// BAD IDEA: s has no space in len for shifting right.
	// copy(s[i+1:], s[i:]) // most likely panic: slice bounds out of range

	fmt.Println(s)
}

The correct mental model is: insertion is always an operation that changes the length, which means you’ll almost always have append(...) and обязательно s = ....

3. Inserting a block of elements

Now let’s make it a bit more complex: insert not one element, but a whole block. For example, we had [10, 20, 30], and we want to insert [7, 8] at position 1, so it becomes [10, 7, 8, 20, 30].

The logic is the same, only instead of “+1” we do “+k”, where k = len(add).

There’s an interesting point here: how do we expand len by k at once? A convenient option for learning is to append k zeros:

s = append(s, make([]int, k)...)

This looks like magic, but it’s actually very down-to-earth: make([]int, k) creates a slice of length k filled with zeros, and ... “unpacks” it as a set of elements for append.

Let’s implement the function:

package main

import "fmt"

func insertSlice(s []int, i int, add []int) []int {
	if i < 0 || i > len(s) {
		return s
	}

	k := len(add)
	if k == 0 {
		return s // nothing to insert
	}

	s = append(s, make([]int, k)...)
	copy(s[i+k:], s[i:]) // shift the tail to the right by k
	copy(s[i:], add)     // copy the inserted block into the freed space
	return s
}

func main() {
	s := []int{10, 20, 30}
	s = insertSlice(s, 1, []int{7, 8})

	fmt.Println(s) // [10 7 8 20 30]
}

Two consecutive copy calls are fine. The first frees space, the second fills it with the needed values. If you do it the other way around, you’ll overwrite what you still need to shift, and the result will be… creative. And a creative result in algorithms is almost always a bug, not art.

4. Filtering: new slice vs in-place

Filtering is when we take the original list and want to keep only the elements that satisfy a condition. For example: “keep all values >= 50”. It sounds simple, but it quickly runs into a question: where do we put the result?

There are two basic approaches:

  • build a new slice (don’t touch the original),
  • “compact” within the same backing array (in-place) by reducing the length.

Both approaches are valid. They just have different properties, and it’s important to choose deliberately rather than “whatever worked”.

Filtering into a new slice

Filtering into a new slice is the most beginner-friendly approach. You have input s, you create out, append matching elements there, and return out. The original s stays the same (which is often nice and safe).

Typically we create out with zero length but pre-allocated capacity so we don’t trigger lots of reallocations:

out := make([]int, 0, len(s))

Let’s write a function “keep elements not less than min”:

package main

import "fmt"

func filterMinNew(s []int, min int) []int {
	out := make([]int, 0, len(s))
	for _, v := range s {
		if v >= min {
			out = append(out, v)
		}
	}
	return out
}

func main() {
	s := []int{10, 50, 30, 80, 40}
	t := filterMinNew(s, 40)

	fmt.Println("s =", s) // s = [10 50 30 80 40]
	fmt.Println("t =", t) // t = [50 80 40]
}

This reads almost like plain English: “create out, iterate over s, if v matches—append it to out”. Sometimes that matters more than micro-optimizations, especially while you’re learning.

In-place filtering: out := s[:0]

In-place filtering is when we want to reuse the same backing array, but arrange things so the “good elements” end up at the beginning and the result length becomes smaller. This is useful when data is large and you don’t want extra allocations. But you must think more carefully, because you’re changing the underlying array.

The classic trick (officially OK and widely used) looks like this:

out := s[:0]

What does it mean? We take the same backing array, but say: “my result is empty for now (len = 0), but there’s storage under the hood.” Then we append matching elements into out—they’ll be written starting from the beginning of the backing array. In the end, out will refer to the same backing array as s, but will have a smaller length and “compacted” values.

Implementation:

package main

import "fmt"

func filterMinInPlace(s []int, min int) []int {
	out := s[:0] // same backing array, but len = 0
	for _, v := range s {
		if v >= min {
			out = append(out, v)
		}
	}
	return out
}

func main() {
	s := []int{10, 50, 30, 80, 40}
	out := filterMinInPlace(s, 40)

	fmt.Println("out =", out) // out = [50 80 40]
	fmt.Println("s   =", s)   // s   = [50 80 40 80 40] (contents changed!)
}

Yes, printing s looks strange. And it’s not a bug: we really overwrote the first elements of the backing array with the result values, while the tail “remained as it was”. We just usually stop treating it as a valid list.

The tail after in-place filtering and clear

After in-place filtering, we have two “layers of truth”:

  • out — the correct result (shorter len, compacted),
  • the old s — now a historical artifact, because its len is the old one while the backing array is partially overwritten.

The tail (elements after len(out) within cap) can be dangerous not only for “nice-looking output”. It can retain memory if elements are reference types: strings, pointers, other slices, maps, interfaces. Then the garbage collector sees references in the tail of the backing array and considers the data still reachable, even if logically you “removed” it.

In our case with int it’s not scary (numbers don’t retain anything), but it’s better to build the habit early.

Let’s refine the function so it “sanitizes the tail”:

package main

import "fmt"

func filterMinInPlaceAndClearTail(s []int, min int) []int {
	out := s[:0]
	for _, v := range s {
		if v >= min {
			out = append(out, v)
		}
	}

	// The tail in the original backing array is no longer needed.
	// For int this isn't required, but as a habit it's useful.
	clear(s[len(out):])

	return out
}

func main() {
	s := []int{10, 50, 30, 80, 40}
	out := filterMinInPlaceAndClearTail(s, 40)

	fmt.Println("out =", out) // out = [50 80 40]
	fmt.Println("s   =", s)   // s   = [50 80 40 0 0]
}

Important: clear does not change the length. It only writes the zero value into the specified range. If you clear s[len(out):], you’ll zero out the tail (in the example, these will be zeros). For reference types this will be nil or an empty value, and it becomes easier for the garbage collector to “let go” of memory.

How to choose an approach without fanaticism

In real life, the choice is rarely “right/wrong”. It’s usually “easier to maintain” versus “more memory-efficient”. For a beginner, the “new slice” approach almost always wins because it’s more transparent: the original data doesn’t change, there are no surprises with aliasing, tails, or unexpected overwrites.

In-place filtering is useful when you clearly understand what you’re doing and when it matters not to allocate new buffers. It’s popular in code that works with large volumes of data, in loops, in parsers, in log processing. But even there, people often add a clear of the tail so they don’t retain memory via extra references.

Remember a simple rule that saves nerves: if you chose in-place, stop treating the old slice s as the source of truth. The truth is now in out.

5. Mini app: inserting and filtering “tasks”

Now we’ll assemble a small console-program skeleton that stores a list of numbers (our “tasks”) and allows inserting and filtering. We won’t build a fancy CLI parser (that’s a separate science); instead we’ll do a simple command-reading loop via fmt.Scan to focus on slices.

The commands will be:

  • push x — append to the end,
  • ins i x — insert x at position i,
  • min m — keep only elements >= m (we’ll show both variants: new and in-place),
  • print — print the list,
  • exit — exit.

Skeleton for the command-reading loop

package main

import (
	"fmt"
)

func main() {
	tasks := make([]int, 0, 16)

	for {
		var cmd string
		if _, err := fmt.Scan(&cmd); err != nil {
			break // stdin ended
		}

		if cmd == "exit" {
			break
		}
		if cmd == "print" {
			fmt.Println(tasks)
		}
	}
}

So far it can’t do anything except print and exit, but it’s a good “skeleton”: we have tasks, a loop, and command reading. Next we’ll add insertion.

Adding push and ins

package main

import "fmt"

func insertAt(s []int, i int, v int) []int {
	if i < 0 || i > len(s) {
		return s
	}
	s = append(s, 0)
	copy(s[i+1:], s[i:])
	s[i] = v
	return s
}

func main() {
	tasks := make([]int, 0, 16)

	for {
		var cmd string
		if _, err := fmt.Scan(&cmd); err != nil {
			break
		}

		if cmd == "push" {
			var x int
			fmt.Scan(&x)
			tasks = append(tasks, x)
		}

		if cmd == "ins" {
			var i, x int
			fmt.Scan(&i, &x)
			tasks = insertAt(tasks, i, x)
		}

		if cmd == "print" {
			fmt.Println(tasks)
		}

		if cmd == "exit" {
			break
		}
	}
}

The key point is tasks = insertAt(tasks, i, x)—we store the result. Insertion changes the length and can trigger reallocation, so you can’t ignore the returned value (this is the same class of rule as with append).

Adding filtering in two ways

Let’s make two functions: one into a new slice, and the other in-place.

package main

import "fmt"

func filterMinNew(s []int, min int) []int {
	out := make([]int, 0, len(s))
	for _, v := range s {
		if v >= min {
			out = append(out, v)
		}
	}
	return out
}

func filterMinInPlace(s []int, min int) []int {
	out := s[:0]
	for _, v := range s {
		if v >= min {
			out = append(out, v)
		}
	}
	clear(s[len(out):]) // the tail is no longer needed
	return out
}

func main() {
	tasks := []int{10, 50, 30, 80, 40}

	fmt.Println(filterMinNew(tasks, 40))     // [50 80 40]
	fmt.Println(filterMinInPlace(tasks, 40)) // [50 80 40]
}

Even in this example you can see the difference: filterMinNew does not damage the original tasks, while filterMinInPlace does (more precisely, it “repurposes the backing array for the result”). And that’s fine—just choose deliberately.

6. Common mistakes

Mistake #1: forgetting to increase the length before shifting right.
When inserting, a beginner often writes copy(s[i+1:], s[i:]) but forgets to do s = append(s, 0) (or expand by k for a block). As a result, you either get a panic because of invalid slice bounds, or “silent data corruption”. Insertion almost always starts by increasing len.

Mistake #2: mixing up the boundary for i during insertion and accidentally forbidding inserting at the end.
If you check i >= len(s) and return the original slice, you accidentally forbid the valid case “insert at the end”. For insertion, indices from 0 to len(s) inclusive are allowed.

Mistake #3: expecting in-place filtering not to change the original slice.
The pattern out := s[:0] is specifically designed to reuse the backing array. That means the original s will be overwritten at the beginning. If elsewhere in the code you keep printing s and wondering why it “broke”, the problem isn’t Go—it’s your expectations.

Mistake #4: not controlling the tail after in-place filtering when elements are reference types.
After compaction, the “extra” elements remain beyond len(out) but still within cap of the backing array. If they hold references, they can retain memory until you zero them out. A careful clear(s[len(out):]) after in-place filtering is a simple discipline that prevents unpleasant surprises and matches the practical model “invisible elements are still alive”.

1
Task
Go SELF, level 13, lesson 4
Locked
Insert code
Insert code
1
Task
Go SELF, level 13, lesson 4
Locked
Insert a batch
Insert a batch
1
Task
Go SELF, level 13, lesson 4
Locked
Filter into a new slice
Filter into a new slice
1
Task
Go SELF, level 13, lesson 4
Locked
In-Place Filter
In-Place Filter
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION