素数筛算法解析

      素数也叫质数,凡是只能被1和自身整除的整数都叫做素数(数学概念),光是知道这个定义我们就可以写出列举某个范围内的素数的算法了。

//算法1
func main() {
	t1 := time.Now().UnixNano()
	//计数器,每10个数换行
	count := 0
	//列举10000以内的素数
	for i := 2; i <= 10000; i++ {
		isPrime := true
		for j := 2; j < i; j++ {
			if i%j == 0 {
				isPrime = false
				break
			}
		}
		if isPrime {
			count++
			if count%10 != 0 {
				fmt.Printf("%d,", i)
			} else {
				fmt.Printf("%d\n", i)
			}
		}
	}
	t2 := time.Now().UnixNano()
	fmt.Printf("\n消耗时间:%d ms", (t2-t1)/1000000)
}
//---------------------------------------------------------
算法平均用时(取10次测试结果平均值):95.8ms

      可能大多数人会直接想到 “算法1”,这种写法直接就根据素数的定义来了(只能被1和自身【假设为n(下同)】整除,换言之就是[2,n-1]区间的数都不能整除)。
      上述“算法1”只是我们初步的构思,运行结果肯定是没有问题,但是考虑到性能问题我们还可以进行优化:
       素数中除了2之外都是奇数,即都不能被2整除,因此循环测试数字的时候只需要测试奇数,这样可以省掉一半的数字测试;
       数字整除除数只能在被除数一半之内(这里依旧考虑[2,n-1]区间),比如:100的除数区间是[2,99],但实际上最大除数是50,超过一半的数字全都不能整除,因此50之后的数字全都没有测试的必要,这样一来又可以省掉一半的除法运算;

//算法1【改进】
func main() {
	t1 := time.Now().UnixNano()
	//计数器,每10个数换行
	count := 1
	fmt.Printf("%d,", 2)
	for i := 3; i <= 10000; i += 2 {
		isPrime := true
		for j := 2; j < i/2; j++ {
			if i%j == 0 {
				isPrime = false
				break
			}
		}
		if isPrime {
			count++
			if count%10 != 0 {
				fmt.Printf("%d,", i)
			} else {
				fmt.Printf("%d\n", i)
			}
		}
	}

	t2 := time.Now().UnixNano()
	fmt.Printf("\n消耗时间:%d ms\n", (t2-t1)/1000000)
}
//---------------------------------------------------------
算法平均用时(取10次测试结果平均值):75.3ms

      从测试结果上看,平均时间提升了20ms左右。测试次数肯定是不够的,但是速度得到明显提升却是一个可想而知的结果。如果测试范围扩大到10w、100w、1000w,那么整个程序执行时间所体现出来的性能差距将会越来越大。(代码中为了统一,直接理所当然地将2作为素数输出,让循环从3开始,保证每次外层循环测试的都是奇数)
      上述算法是以一种最简单,最直观的方式列举测试范围内的素数列(相信突然被要求“列出某范围内的素数列”,大多数人都会想到上面的算法)。但近日来我接触到了一种新的思路——“素数筛”。“素数筛”讲的是给定一个从1开始的、连续的数列,每次都从中筛选掉非素数,从而得到新数列,而新数列中的最小的数字即为本轮筛选的素数。
举个例子:给定一个数字区间[1,30]。
1 , 2,3 , 4,5 , 6,7 , 8,9 , 10
11 , 12 , 13,14 , 15 , 16 , 17 , 18 , 19 , 20
21 , 22 , 23 , 24 , 25 , 26 , 27 , 28 , 29 , 30
判断1不是素数,筛掉,得到新数列:
2,3 , 4,5 , 6,7 , 8,9 , 10
11 , 12 , 13,14 , 15 , 16 , 17 , 18 , 19 , 20
21 , 22 , 23 , 24 , 25 , 26 , 27 , 28 , 29 , 30
此时新数列最小数是2,为本轮筛选出来的素数。

筛掉2的倍数,得到新数列:
3 , 5 ,7 , 9
11 , 13 , 15 , 17 , 19
21 , 23 , 25 , 27 , 29
此时新数列最小数是3,为本轮筛选出来的素数。

筛掉3的倍数,得到新数列:
5 ,7
11 , 13 , 17 , 19
23 , 25 , 29
重复以上操作,最终就可以得到一个素数列,这就是“素数筛”的思想,通过每轮的筛选,得到新数列的首个元素(即最小元素)为本轮筛选出来的素数。
代码中体现为“将当前的数字去除以已知的素数列,如果均不能整除,则说明该数字的素数,并加入素数列中”。

//算法2
type primeNode struct {
	data int
	next *primeNode
}

func filterPrime(current int, prime *primeNode) bool {
	if prime == nil {
		return true
	} else {
		if prime.data != 0 && current%prime.data != 0 {
			return filterPrime(current, prime.next)
		} else {
			return false
		}
	}
}

func main() {
	t1 := time.Now().UnixNano()
	//计数器,每10个元素换行
	count := 1
	fmt.Printf("%v,", 2)
	//初始化一个节点,即首元结点,这里默认2为素数
	pLink := &primeNode{
		data: 2,
	}
	currentNode := pLink
	for i := 3; i <= 10000; i++ {
		if filterPrime(i, pLink) {
			currentNode.next = &primeNode{
				data: i,
			}
			currentNode = currentNode.next
			count++
			if count%10 == 0 {
				fmt.Printf("%v\n", i)
			} else {
				fmt.Printf("%v,", i)
			}
		}
	}
	t2 := time.Now().UnixNano()
	fmt.Printf("\n消耗时间:%d ms\n", (t2-t1)/1000000)
	fmt.Printf("素数数量:%d", count)
}
//---------------------------------------------------------
算法平均用时(取10次测试结果平均值):55.2ms

代码中使用了链表结构,链表存的是整个素数列,链表中由若干个素数结点组成。data是数据域(即存数字的),next是指针域(用来指向下一个素数结点)。【tip:因为go语言中有指针的概念,所以这里使用了指针,像C、C++等有指针概念的通用。如果是Java语言的话这里可以考虑用ArrayList等长度可变的动态数组来添加素数构成素数列】

//算法3【并发版素数筛算法】
//给出一个数字用来判断
func numGiven(ch chan int) {
	for i := 2; i <= 10000; i++ {
		ch <- i
	}
}

func getPrime(chOld, chNew chan int, prime int) {
	go func() {
		for {
			if n := <-chOld; n%prime != 0 || n == 10000 {
				chNew <- n
			}
		}
	}()
}

func main() {
	t1 := time.Now().UnixNano()
	//计数器,每10个数字换行
	count := 0
	ch := make(chan int)
	go numGiven(ch)
	for {
		prime := <-ch
		if prime == 10000 {
			break
		}
		count++
		if count%10 != 0 {
			fmt.Printf("%d,", prime)
		} else {
			fmt.Printf("%d\n", prime)
		}
		cho := make(chan int)
		getPrime(ch, cho, prime)
		ch = cho
	}
	t2 := time.Now().UnixNano()
	fmt.Printf("\n消耗时间:%d ms\n", (t2-t1)/1000000)
	fmt.Printf("素数个数:%d", count)
}
//---------------------------------------------------------
算法平均用时(取10次测试结果平均值):77.4ms

      上面算法4就是网上比较有代表性的go并发版素数筛,说是并发实际上还是串行执行的方式。getPrime函数里利用goroutine和channel的特性依次向上取值直到numGiven往通道中写入一个当前值n(n是当前值,下同),然后从第二个启动的goroutine中的channel读取到值并模2,即第二个goroutine此时是n%2,如果不能整除则将值写入另一个channel,这个channel被写入值后第三个goroutine解除阻塞并读取该channel中存的当前值n,并去模3,后面以此类推直到写入当前已开启的最后一个goroutine,在模prime不为0之后将当前值n写入通道,并由main函数中的通道ch读取输出。
      这个算法比较容易混乱的地方在于多个goroutine并发导致读者可能会一时间不知道某个通道阻塞了该由哪个goroutine去执行,还有goroutine中的channel读取的值来自于哪里,写进去的值该由哪里读取? 图解值的流转顺序:
素数筛算法解析_第1张图片
      从图中可以看出,数字获取是依次向上直到来到数字产生函数numGiven()处读取到数字,然后依次向下做除以已知素数的判断。举例(结合上图):cho想要得到数字首先ch读取到一个数字,而ch的数字又来源于上一个goroutine中cho的数字,依次类推知道从numGiven()拿到一个数字写入第二个goroutine中的ch,这时候需要判断当前读取到的数字是否能整除本轮素数2,如果可以则重新获取ch中的数字,即重新从numGiven()拿一个新数字。如果不能整除本轮素数2,则将值写入cho,并由goroutine3中的ch读取。只要中间有一个素数被整除,则需要重新往上面拿到一个新值并重新做判断,直到已知的所有素数均不能被整除则认为该新数字为素数并输出。
      虽然代码中用了大量的goroutine和channel实现goroutine之间的通信,看起来是一个并发的逻辑。但实际上goroutine之间的执行顺序是确定的,因此实质上还是一个串行逻辑。从程序的平均耗时来看确实也体现不出哪里的性能优势。

你可能感兴趣的:(算法Go实现)