让我们测试一下一些排序算法

92 阅读9分钟

排序算法在软件开发中以这种或那种方式被大量使用。根据语言的不同,你可能会有一些方便的排序功能,但根据使用情况,知道哪种排序算法是在引擎盖下应用的,是衡量变化对工作软件影响的关键。

在这篇文章中,我想探讨一些排序算法的实现,但我想通过单元测试来实现。我在网上没有看到很多探索这种方法的内容,所以我希望这篇文章能激发你的兴趣。

关于什么是排序算法的回顾

我将使用的大部分排序算法的定义都是从维基百科上摘录的,所以这里有一个总结来启动我们。

排序算法是一种将列表中的元素按一定顺序排列的算法。

借自维基百科

我们将如何处理这个问题

这里的想法是实现一个或两个排序算法,但这样做是由测试驱动的。基本上,我们会先写一个测试,有一个简单的输入和预期输出的场景。这将会失败,因为还没有任何代码。然后,我们将写最小的必要代码来满足测试,并重复这个过程。这种技术的名称是测试驱动开发(TDD)。如果你对这个过程感到好奇,或者它对你来说听起来不熟悉,请访问这篇文章

我将使用的语言是Go,为了简单起见,使用一个包含测试和实现的单一文件。

如果你对Go语言不熟悉,不要担心。我将发布不同的代码片段来说明每一个步骤,就像我们在一个文件中上下滚动一样。这里的想法是探索技术,而不是Go语言的语法或特殊性。

你可能会注意到,我将使用数组这个术语,尽管在Go中,数组和片断是有区别的,而且从技术上讲,我将在代码中使用片断。

我也会试着描述每一个步骤,所以假装我们在做一些配对编程

让我们开始吧

好的。正如我在下面提到的,我想从一个简单的测试开始,这将迫使我添加最少的代码,只是为了让事情顺利进行,然后我们可以在上面添加更多的内容。就我打算选择哪种算法而言,我将采用泡沫排序。然后我们评估这对我们来说是否足够好,如果不是,我们就重构我们的代码,选择不同的实现。这里重要的是:在最后,无论我们选择哪种算法,测试都需要通过,因为我们仍然希望对我们的元素进行排序。

另外,为了简单起见,我们的排序算法将只处理数字(更准确地说,是整数)。

让我们从最简单的输入开始,这将是一个空数组。为什么呢?因为如果我们想对一个空数组进行排序,其结果就应该是一个空数组我想采用的方法是,尽可能用最少的动作来进行,因为我们需要不断的反馈。我们可以从一个有4个元素的数组开始,然后写一个测试,期望这个数组被排序,但是这需要我们花更多的时间写代码,更少的时间写测试。我想获得一些平衡,确保我们不会留下任何边缘情况。

package sorting_test

import (
	"testing"

	"github.com/matryer/is"
)

func TestSort(t *testing.T) {
	t.Run("should return same value when array is empty", func(t *testing.T) {
		is := is.New(t)
		elements := [0]int{}
		expected := [0]int{}

		is.Equal(Sort(elements), expected)
	})
}

go test 运行上面的测试,会失败。

go test ./.
2021/06/08 14:06:17 exit status 2
# alabeduarte.com_test [alabeduarte.com.test]
./sorting_test.go:15:12: undefined: Sort
FAIL    alabeduarte.com [build failed]
FAIL
FAIL (0.22 seconds)

这是因为Sort 方法没有定义在任何地方。让我们做最小的努力,通过定义这个方法,让它返回一个空数组,这样我们的测试就可以通过了!

// Implementation file

func Sort(_elements []int) []int {

	return []int{}
}

注意,我们总是返回一个空数组,无论如何

运行测试。

go test ./.
ok      alabeduarte.com 0.940s
PASS (0.39 seconds)

现在让我们添加另一个测试场景,它将迫使我们写一些硬编码响应以外的东西。

// Test file

func TestSort(t *testing.T) {

  // previous test scenario is omitted here ...

  t.Run("should return same value when array has only one element", func(t *testing.T) {
    is := is.New(t)
    elements := []int{1}
    expected := []int{1}

    is.Equal(Sort(elements), expected)
  })
}
go test ./.
2021/06/08 14:04:49 exit status 1
        sorting_test.go:29: [] != [1]
--- FAIL: TestSort (0.00s)
    --- FAIL: TestSort/should_return_same_value_when_array_has_only_one_element (0.00s)
FAIL
FAIL    alabeduarte.com 0.101s
FAIL
FAIL (0.31 seconds)

这是预料之中的,因为我们的实现代码总是返回一个空数组。让我们改变这一点,使测试通过,但要以一种需要尽可能少的努力来实现的方式。

// Implementation file

func Sort(elements []int) []int {

	return elements
}

注意,返回元素本身就满足了我们到目前为止的两种情况

go test ./.
ok      alabeduarte.com 0.766s
PASS (0.39 seconds)

现在我们的两个场景都通过了,让我们评估一下到目前为止的代码...

好吧,看看这段代码,除了我们的测试目前有点冗长之外,我想不出有什么可以改进的。我们在测试中定义了变量elementsexpected ,然后我们做了以下的评估。

is.Equal(Sort(elements), expected)

考虑到我们的测试目前是如此简单,我觉得我们可以做内联的事情,所以让我们重构它。

// Test file

func TestSort(t *testing.T) {
	t.Run("should return same value when array is empty", func(t *testing.T) {
		is := is.New(t)

		is.Equal(Sort([]int{}), []int{})
	})

	t.Run("should return same value when array has only one element", func(t *testing.T) {
		is := is.New(t)

		is.Equal(Sort([]int{1}), []int{1})
	})
}

现在,运行测试,它们仍然应该是通过的,因为我们没有添加任何新的东西。

go test ./.
ok      alabeduarte.com (cached)
PASS (0.24 seconds)

很好。现在所有的东西都是 "绿色 "的(也就是通过),让我们添加一个新的场景,它实际上需要我们应用任何种类的算法。然而,让我们添加一些非常简单的东西,比如两个数字。

// Test file

func TestSort(t *testing.T) {
  // ...

	t.Run("should return the lowest element followed by the largest element", func(t *testing.T) {
		is := is.New(t)

		is.Equal(Sort([]int{2, 1}), []int{1, 2})
	})
}

正如预期的那样,该测试应该失败。

go test ./.
2021/06/08 14:21:11 exit status 1
        sorting_test.go:31: [2 1] != [1 2]
--- FAIL: TestSort (0.00s)
    --- FAIL: TestSort/should_return_the_lowest_element_followed_by_the_largest_element (0.00s)
FAIL
FAIL    alabeduarte.com 0.098s
FAIL
FAIL (0.34 seconds)

现在,让我们让它通过!

// Implementation file

func Sort(elements []int) []int {

	if len(elements) <= 1 {
		return elements
	}

	return []int{elements[1], elements[0]}
}
go test ./.
ok      alabeduarte.com 0.759s
PASS (0.37 seconds)

这很好!测试都通过了。你可能会想......我们的实现听起来不是很可靠,不是吗?有几件事情可能会出错......而且,感觉我们在作弊,因为我们总是抓取第一个和第二个元素,然后把它们按相反的顺序返回,说实话,没有进行排序。让我们把代码分解开来,进一步讨论我们接下来可以做什么。

// Implementation file

func Sort(elements []int) []int {

  // here we are checking if the length is less or equal to 1
  // which means that if the array is empty we will return the elements
  // themselves (an empty array) and if the array has only one element, it will
  // also return itself.
	if len(elements) <= 1 {
		return elements
	}

  // On this case, we're grabbing the second elemnt and the first element and
  // swapping the order of the two
	return []int{elements[1], elements[0]}
}

基本上,如果数组有2个以上的元素,我们的代码将只返回前两个元素的反向顺序。所以这里有几件事情可能会在这个算法中出错。

  • 当元素的长度大于2时,我们的排序算法将忽略其余的元素,返回一个元素较少的数组
  • 当元素已经被排序时,我们的排序算法将通过交换前两个元素来破坏它。

我们应该修复它们吗?是的,但我们应该在有测试的情况下才这样做。

很明显,我们的代码正在做错误的事情。但是,尽管听起来很诱人,我们还是要在有测试方案的情况下才将新的代码添加到我们的实现中,以证明其存在的合理性

因此,让我们从下面的测试场景开始。

// Test file

func TestSort(t *testing.T) {
  // ...

	t.Run("should return an array with the same length as the one provided as an input", func(t *testing.T) {
		is := is.New(t)

		sortedElements := Sort([]int{2, 1, 4, 3})
		actualLength := len(sortedElements)

		is.Equal(actualLength, 4)
	})
}

正如预期的那样,测试会失败。

go test ./.
2021/06/08 14:40:57 exit status 1
        sorting_test.go:44: 2 != 4
--- FAIL: TestSort (0.00s)
    --- FAIL: TestSort/should_return_an_array_with_the_same_length_as_the_one_provided_as_an_input (0.00s)
FAIL
FAIL    alabeduarte.com 0.101s
FAIL
FAIL (0.34 seconds)

现在让我们让它通过。

// Implementation file

func Sort(elements []int) []int {

	if len(elements) <= 1 {
		return elements
	}

  // take the elements from index 2 onward
	rest := elements[2:]

  // append the rest to the original array we had
	return append([]int{elements[1], elements[0]}, rest...)
}

测试是通过的。

go test ./.
ok      alabeduarte.com 0.801s
PASS (0.47 seconds)

因为我们要确保数组的长度总是相同的,但是我们仍然符合其他的测试方案。然而,现在的实现有点笨拙,这是一个信号,是时候实际实现这个算法了

让我们添加一个场景,这个场景足够简单,可以说明我们可以对超过2个元素进行排序,而不会有一个笨重的实现。所以这次让我们使用3个元素。

// Test file

func TestSort(t *testing.T) {
  // ...

	t.Run("should sort all the elements from the lowest to the largest", func(t *testing.T) {
		is := is.New(t)

		is.Equal(Sort([]int{2, 3, 1}), []int{1, 2, 3})
	})
}

当然,它应该是失败的。

go test ./.
2021/06/08 14:56:24 exit status 1
        sorting_test.go:51: [3 2 1] != [1 2 3]
--- FAIL: TestSort (0.00s)
    --- FAIL: TestSort/should_sort_all_the_elements_from_the_lowest_to_the_largest (0.00s)
FAIL
FAIL    alabeduarte.com 0.099s
FAIL
FAIL (0.40 seconds)

现在让我们在这里实现一些排序算法。现在,让我们使用名为 "泡沫排序"的算法。

正如维基百科上所说的,泡沫排序是最简单的排序算法之一,易于理解和实现,但它的效率在较大的列表上会急剧下降。更多细节请看这里

如果你想了解更多关于这个算法在围棋中的实现,我也建议使用这个材料:https://tutorialedge.net/courses/go-algorithms-course/21-bubble-sort-in-go/

// Implementation file

func Sort(elements []int) []int {

	n := len(elements)
	if n <= 1 {
		return elements
	}

	swapped := true

	for swapped {
		swapped = false

		for i := 0; i < n-1; i++ {
			if elements[i] > elements[i+1] {
				elements[i], elements[i+1] = elements[i+1], elements[i]
				swapped = true
			}
		}
	}

	return elements
}

通过上面的实现,所有的测试都应该通过。

go test ./.
ok      alabeduarte.com 0.858s
PASS (0.51 seconds)

效率

我们的算法(冒泡排序)并不是最高效的。该算法的复杂度是O(n²),其中n是被排序的元素数。这意味着它的效率随着元素数量的增加而降低。在这里我们还可以使用其他的选择,比如插入排序选择排序,它们被认为是我的效率。

在不一定要改变任何东西的情况下,让我们用Go测试基准对我们目前的算法做一个基准测试。

首先,让我们创建一个小函数(在我们的测试文件中)来生成给定长度的随机元素。

// Test file

func generateRandomElements(n int) []int {

	// initialise a slice with length and capacity of "n"
	elements := make([]int, n, n)

	// populate the slice with random elements
	for i, _ := range elements {
		elements[i] = rand.Int()
	}

	return elements
}

现在让我们创建一个函数,它将在我们的方法排序上进行迭代,把testing.B 作为一个参数。

// Test file

func benchmarkBubbleSort(n int, b *testing.B) {

for i := 0; i < b.N; i++ {
  elements := generateRandomElements(n)

  // sort elements
  Sort(elements)
}

最后,让我们创建一些基准函数来测试我们代码的效率。

// Test file

func BenchmarkBubbleSort3(b *testing.B)      { benchmarkBubbleSort(3, b) }
func BenchmarkBubbleSort10(b *testing.B)     { benchmarkBubbleSort(10, b) }
func BenchmarkBubbleSort20(b *testing.B)     { benchmarkBubbleSort(20, b) }
func BenchmarkBubbleSort50(b *testing.B)     { benchmarkBubbleSort(50, b) }
func BenchmarkBubbleSort100(b *testing.B)    { benchmarkBubbleSort(100, b) }
func BenchmarkBubbleSort1000(b *testing.B)   { benchmarkBubbleSort(1_000, b) }
func BenchmarkBubbleSort100000(b *testing.B) { benchmarkBubbleSort(100_000, b) }

当运行以下命令时。

下面是结果。

go test -bench=.
goos: darwin
goarch: amd64
pkg: alabeduarte.com
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkBubbleSort3-12                 17688249                67.22 ns/op
BenchmarkBubbleSort10-12                 4173184               303.4 ns/op
BenchmarkBubbleSort20-12                 1228124              1031 ns/op
BenchmarkBubbleSort50-12                  240789              4240 ns/op
BenchmarkBubbleSort100-12                  90360             13976 ns/op
BenchmarkBubbleSort1000-12                  1626            733388 ns/op
BenchmarkBubbleSort100000-12                   1        14456783580 ns/op

正如你所看到的,当数组中有100,000个元素时,我的机器需要14456783580 纳秒来进行排序,这相当于大约14秒。

让我们试试用Go的标准库实现对元素进行排序的方法。让我们创建一个小函数来生成随机元素并调用包sort中的Ints方法。

func benchmarkGoSort(n int, b *testing.B) {

	for i := 0; i < b.N; i++ {
		elements := generateRandomElements(n)

		// sort elements
		sort.Ints(elements)
	}
}

现在让我们创建类似的基准函数,将我们的Sort functin与sort.Ints函数进行比较。

func BenchmarkGoSort3(b *testing.B)      { benchmarkGoSort(3, b) }
func BenchmarkGoSort10(b *testing.B)     { benchmarkGoSort(10, b) }
func BenchmarkGoSort20(b *testing.B)     { benchmarkGoSort(20, b) }
func BenchmarkGoSort50(b *testing.B)     { benchmarkGoSort(50, b) }
func BenchmarkGoSort100(b *testing.B)    { benchmarkGoSort(100, b) }
func BenchmarkGoSort1000(b *testing.B)   { benchmarkGoSort(1_000, b) }
func BenchmarkGoSort100000(b *testing.B) { benchmarkGoSort(100_000, b) }

现在让我们运行基准。

go test -bench=.
goos: darwin
goarch: amd64
pkg: alabeduarte.com
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkBubbleSort3-12                 18284776                67.53 ns/op
BenchmarkBubbleSort10-12                 4207515               295.9 ns/op
BenchmarkBubbleSort20-12                 1201567              1006 ns/op
BenchmarkBubbleSort50-12                  276207              4262 ns/op
BenchmarkBubbleSort100-12                  89107             13813 ns/op
BenchmarkBubbleSort1000-12                  1620            755969 ns/op
BenchmarkBubbleSort100000-12                   1        14532398965 ns/op

BenchmarkGoSort3-12                      9781417               124.5 ns/op
BenchmarkGoSort10-12                     2595421               439.4 ns/op
BenchmarkGoSort20-12                     1000000              1108 ns/op
BenchmarkGoSort50-12                      357010              3252 ns/op
BenchmarkGoSort100-12                     160202              7618 ns/op
BenchmarkGoSort1000-12                     10000            100856 ns/op
BenchmarkGoSort100000-12                      75          16205598 ns/op

我们可以看到,在20个元素之前,我们使用气泡排序的算法似乎要好一点,在这里go标准库开始大放异彩,比我们的算法快得多。

既然go标准库更有效率,让我们改变我们的实现,用它来代替,并重新进行测试。

// Implementation file

func Sort(elements []int) []int {

	sort.Ints(elements)

	return elements
}

所有的测试都应该是通过的。

go test -v
=== RUN   TestSort
=== RUN   TestSort/should_return_same_value_when_array_is_empty
=== RUN   TestSort/should_return_same_value_when_array_has_only_one_element
=== RUN   TestSort/should_return_the_lowest_element_followed_by_the_largest_element
=== RUN   TestSort/should_return_an_array_with_the_same_length_as_the_one_provided_as_an_input
=== RUN   TestSort/should_sort_all_the_elements_from_the_lowest_to_the_largest
--- PASS: TestSort (0.00s)
    --- PASS: TestSort/should_return_same_value_when_array_is_empty (0.00s)
    --- PASS: TestSort/should_return_same_value_when_array_has_only_one_element (0.00s)
    --- PASS: TestSort/should_return_the_lowest_element_followed_by_the_largest_element (0.00s)
    --- PASS: TestSort/should_return_an_array_with_the_same_length_as_the_one_provided_as_an_input (0.00s)
    --- PASS: TestSort/should_sort_all_the_elements_from_the_lowest_to_the_largest (0.00s)
PASS
ok      alabeduarte.com 0.920s

如果你有兴趣看到我们构建的整个代码,请解压下面的部分。

(点击展开)

package sorting_test

import (
	"math/rand"
	"sort"
	"testing"

	"github.com/matryer/is"
)

// Implementation:
//
// Sort will receive a slice as an input and it will return another slice but
// sorted.
func Sort(elements []int) []int {

	n := len(elements)
	if n <= 1 {
		return elements
	}

	swapped := true

	for swapped {
		swapped = false

		for i := 0; i < n-1; i++ {
			if elements[i] > elements[i+1] {
				elements[i], elements[i+1] = elements[i+1], elements[i]
				swapped = true
			}
		}
	}

	return elements
}

// Unit tests:
func TestSort(t *testing.T) {

	t.Run("should return same value when array is empty", func(t *testing.T) {
		is := is.New(t)

		is.Equal(Sort([]int{}), []int{})
	})

	t.Run("should return same value when array has only one element", func(t *testing.T) {
		is := is.New(t)

		is.Equal(Sort([]int{1}), []int{1})
	})

	t.Run("should return the lowest element followed by the largest element", func(t *testing.T) {
		is := is.New(t)

		is.Equal(Sort([]int{2, 1}), []int{1, 2})
	})

	t.Run("should return an array with the same length as the one provided as an input", func(t *testing.T) {
		is := is.New(t)

		sortedElements := Sort([]int{2, 1, 4, 3})
		actualLength := len(sortedElements)

		is.Equal(actualLength, 4)
	})

	t.Run("should sort all the elements from the lowest to the largest", func(t *testing.T) {
		is := is.New(t)

		is.Equal(Sort([]int{2, 3, 1}), []int{1, 2, 3})
	})
}

// Benchmarks:

func generateRandomElements(n int) []int {

	// initialise a slice with length and capacity of "n"
	elements := make([]int, n, n)

	// populate the slice with random elements
	for i, _ := range elements {
		elements[i] = rand.Int()
	}

	return elements
}

func benchmarkBubbleSort(n int, b *testing.B) {

	for i := 0; i < b.N; i++ {
		elements := generateRandomElements(n)

		// sort elements
		Sort(elements)
	}
}

func benchmarkGoSort(n int, b *testing.B) {

	for i := 0; i < b.N; i++ {
		elements := generateRandomElements(n)

		// sort elements
		sort.Ints(elements)
	}
}

func BenchmarkBubbleSort3(b *testing.B)      { benchmarkBubbleSort(3, b) }
func BenchmarkBubbleSort10(b *testing.B)     { benchmarkBubbleSort(10, b) }
func BenchmarkBubbleSort20(b *testing.B)     { benchmarkBubbleSort(20, b) }
func BenchmarkBubbleSort50(b *testing.B)     { benchmarkBubbleSort(50, b) }
func BenchmarkBubbleSort100(b *testing.B)    { benchmarkBubbleSort(100, b) }
func BenchmarkBubbleSort1000(b *testing.B)   { benchmarkBubbleSort(1_000, b) }
func BenchmarkBubbleSort100000(b *testing.B) { benchmarkBubbleSort(100_000, b) }

func BenchmarkGoSort3(b *testing.B)      { benchmarkGoSort(3, b) }
func BenchmarkGoSort10(b *testing.B)     { benchmarkGoSort(10, b) }
func BenchmarkGoSort20(b *testing.B)     { benchmarkGoSort(20, b) }
func BenchmarkGoSort50(b *testing.B)     { benchmarkGoSort(50, b) }
func BenchmarkGoSort100(b *testing.B)    { benchmarkGoSort(100, b) }
func BenchmarkGoSort1000(b *testing.B)   { benchmarkGoSort(1_000, b) }
func BenchmarkGoSort100000(b *testing.B) { benchmarkGoSort(100_000, b) }

最后的想法

如果你读了这篇文章并走到这一步,非常感谢你。我希望我能够说明开发一个由测试驱动的排序算法是怎样的。

你可能已经注意到了,我并不太关心如何选择性能最好的算法实现,而是对经历由测试指导编写代码的过程感兴趣。换句话说,我的意图是让测试能够证明任何一种实现的存在,并避免在没有测试的情况下增加任何额外的代码,我希望我能够让它变得清晰和令人愉快。

谢谢你的阅读

我希望你喜欢这篇文章,如果你有任何反馈或问题,请在alabeduarte@gmail.com,我很高兴听到你的想法,下次会更好!

其他参考资料