go-图数据结构-商品属性筛选

150 阅读9分钟

go-图数据结构-商品属性筛选

图:是一种高等数据结构,我们可以利用图实现很多应用如构建用户的社交关系情况,寻找最短多个地点的最短距离,我们也可以利用图实现对商品属性筛选 2022-09-25-12-11-38-image..png

1. 图

图的类型有无向图/有向图区分/有权图/无权图有较多的形式,最简单的还是属无向无权图

图的存储结构有邻接矩阵/邻接表/十字链表......(不想写了)

给个链接自己去查查

图的实现上我们可以基于链表实现也可以基于数组实现

2. 用例

本文以电商的商品属性筛选为话题讲讲图的应用,个人见解。

在电商商品的详情中通过对属性的筛选选择最终要购买的商品,功能请求较为频繁并且要求能够快速响应结果。

2022-09-25-13-12-27-image..png

如上:以手机属性为话题属性有【网络类型】【机身颜色】【存储容量】(【套餐类型】只有一个值因此省略)

依据属性对应属性值

网络类型 = {
    4G全网通,
    5G全网通
}
机身颜色 = {
    亮黑色,
    白色,
    银色
}
存储容量 = {
    8+128GB,
    8+256GB,
    8+125GB[无快充版],
    8+256GB[无快充版]
}

假设有如下sku

[4G全网通, 亮黑色, 8+128GB]
[4G全网通, 白色, 8+128GB]
[4G全网通, 亮黑色, 8+256GB]
[4G全网通, 白色, 8+256GB]

[5G全网通, 亮黑色, 8+128GB]
[5G全网通, 银色, 8+128GB]
[5G全网通, 亮黑色, 8+256GB]
[5G全网通, 白色, 8+256GB]
[5G全网通, 白色, 8+256GB[无快充版]]
[5G全网通, 银色, 8+128GB[无快充版]]

属性筛选中如点击4G全网通时就只有如下的sku的商品

[4G全网通, 亮黑色, 8+128GB]
[4G全网通, 白色, 8+128GB]
[4G全网通, 亮黑色, 8+256GB]
[4G全网通, 白色, 8+256GB]

这个时候就会显示对应sku交集的属性值亮黑色,白色,8+128GB,8+256GB而与这些sku无交集的属性则不会显示。

在数据表中会构建三个表存储上面的数据属性表,属性值表,sku表

2022-09-25-14-24-19-image..png

通过前台页面点击属性可将【商品id,属性id,属性值id】传递到后台,在后台根据接收到的参数利用sql获取sku集合,再回到程序中对sku集合处理返回可选的属性 --- 这是在不进行任何处理的情况下利用sql的实现方式。

假设网页的访问量较大,数据量较大和查询及处理量这样频繁的访问数据库就会成为灾难,当然可以利用缓存进行优化----本人懒就不谈论了直奔主题

当网站的访问量增大时就需要频繁的访问数据库获取数据则就会存在较大的性能问题,可以利用缓存进行优化而本文主要讲与图的结合

本文主要讲的方式就是利用图来实现该功能的查询。【这就是主题】

3. 实现分析

我们可以依据sku属性的关系构建如下图,图为无方向无权重因为只需要相互关联即可(这图不容易,给个关注吧),图中每种颜色代表一个sku。

2022-09-25-14-59-29-image..png

你看啊,现在随便选择哪个节点就可以很快的找到与之相关的元素,而在图中的虚线主要是因为你可能刚好只选中一个元素因此理论上同一个属性的也是可以再切换选择的。

图可以基于数组实现也可以基于链表的方式进行实现,本案例比较适合用数组的方式实现;理由:链表实现太麻烦了......并且不符合我们的需求,该案例主要的目的是在查询上能够快的查询结果,数组相比链表查询效率会更好。

数组是用一维数组,还是二维数组,还是多维数组呢???有应该如何存储呢?以及数组存储什么信息呢?

0 和 1是行业界无不认识的存在,而0和1可以应用于表示二进制数据但除了二进制外也常应用在事物的判断上,比如在本次的用例中我们会用0和1来判断是否存在关联。

2022-09-25-15-10-13-image..png

如:4G全网通白色有关联则以1表示,而4G全网通银色无关联则以0表示,因此在数组中存储的信息就只有0,1;

graph = []byte{0,0,1,1}

接下来我们应该如何存储呢?如何匹配到对应的数据内容?首先根据属性进行排序或者根据属性类型传递的顺序作为固定的顺序,然后再依据属性构建矩阵图。

首先构建基础矩阵,依据元素的内容构建一个平邻接阵图,在图中优先标记出自己与自己的关联记号

image.png

然后再选择对同级别的类型进行标记,比如只选中【4G】的情况下则【5G】理应需要可以点击。

image 1.png

最后再根据实际sku的属性组合完善最终的关联

image 2.png

这里需要注意一个问题,当关联的属性值有3个及以上时则需考虑查询中会存在多个元素的组合查询的情况。

如输入【4G,白色】

分别获取5G与白色的可选元素
5G   = [1, 1, 1, 1, 1, 1, 1, 1, 1]
白色 = [1, 1, 1, 1, 1, 1, 1, 0, 1]

取交集   //[4G, 5G, 白色, 黑色,银色,128G,256G,128G(无),256G(无)]
5G, 白色 = [1,   1,   1,   1,  1,   1,   1,   0,      1]

如上获取的【5G,白色】的交集可选的元素为[4G, 5G, 白色, 黑色,银色,128G,256G,256G(无)]并且与其可以构成相对应的sku,但实际问题中【5G,白色,128G】是无法构成组合,实际结果应该为[4G, 5G, 白色,黑色,银色, 128G,128G(无)],也就意味着目前构建的图无法满足需求;

解决方案是将图扩大,根据组合的元素也放入到图中;

image 3.png

3. 具体实现

基于程序将图进行实现,首先构建结构体AttrGrap结构体

type AttrGraph struct {
	// 属性信息
	attrs	 [][]string
	// 标记数量
	count 	 int
	// 标题长度
	titleLen int
	// 标题节点
	titleNodes map[string]*titleNode
	titles   []string
	plats    map[string][]byte
}
type titleNode struct {
	// 位置
	site int
	// 传入的属性层级
	ahm int
}

在attrGrap中利用attrs存储属性信息,采取的是二维数组(也可以根据需要转为map进行存储—或者不存储)。因为属性是以字符串的方式传递,但在属性排序之后地图是以下标的方式对应传属性,因此定义titleNode用于记录属性值与地图位置的关系并存储统计的属性层级。存储的图信息是0和1 所以plats采用字节进行存储,titles存储的就是最上方的标题信息。

Untitled..png

继续对程序本身进行初始化,

type Options func(graph *AttrGraph)
// 分开写入
func Attrs(attrs ...[]string) Options {
	return func(graph *AttrGraph) {
		graph.attrs = append(graph.attrs, attrs...)
	}
}

利用Options是考虑可能程序上后续会进行某一系列的初始化而设计,并封装属性的传递方式,而attrs的参数是考虑实际执行中一个属性会对应一组属性值,因此会设计为…[]string方式。


func NewAttrGraph(options ...Options) (g *AttrGraph) {
	g = &AttrGraph{}

	for _, opts := range options {
		opts(g)
	}

	// 对对称线上的元素默认选中
	for _, attr := range g.attrs {
		g.titles = append(g.titles, attr...)
	}
	titleLen  := len(g.titles)
	g.titleNodes = make(map[string]*titleNode, titleLen)
	g.titleLen = titleLen
	g.plats		= make(map[string][]byte)
	// 对自己进行标注并记录属性节点信息
	offset := 0
	for idx, attr := range g.attrs {
		for k, v := range attr {
			g.titleNodes[v] = &titleNode{
				site: k + offset,
				ahm:  idx,
			}
			g.plats[v] = make([]byte, titleLen)
			for i := 0; i < len(attr); i++ {
				g.plats[v][i + offset] = 1
			}
		}
		offset += len(attr)
	}
	return
}

在程序的初始化中所实现的流程有如下

  1. 初始化操作

  2. 基于传递的属性值,进行属性排列

  3. 初始化属性值包含标题长度,图等。

  4. 最后完成初始化图的标记,标记的内容主要有标签自己与自己关联,标签与统计的关联。

在开始具体代码前首先第一步是构建打印和输出,因为没有打印和输出那么后续的代码调整优化以及效果会增加一定的理解难度

func (g *AttrGraph) Print() {
	fmt.Println(g.titles)
	for k, v := range g.plats {
		fmt.Println(k, " : ", v)
	}
}
func (g *AttrGraph) Printf(plats []byte) {
	fmt.Println(g.titles)
	fmt.Println(plats)
}

接下来就是需要具体实现的功能,如下

// 搜索
func (g *AttrGraph) Screen(attrs ...string) []byte
// 属性列表
func (g *AttrGraph) Titles() []string
// 导入,图标记
func (g *AttrGraph) Import(attrs []string) error

Screen在图中查找筛选组合,Titles则是获取图中的属性列表,Import会根据传递的属性字段进行标记

实现Import顺带Titles

var (
	ErrInvalidCoordinate  = errors.New("存在无效信息")
	ErrAttrsImport		  = errors.New("属性输入异常")
)
func (g *AttrGraph) sort(attrs []string) []string {
	return attrs
}
// 导入
func (g *AttrGraph) Import(attrs []string) error {

	if len(attrs) <= 1 {
		return ErrAttrsImport
	}
    attrs = g.sort(attrs)
	// 关联的值
	for idx, value := range attrs {
		arr := make([]string,  len(attrs))
		copy(arr, attrs)
		arr = append(arr[:idx], arr[idx +1 :]...)
		// 组成的key值
		for k, kv := range arr {
			g.sign(value, kv)
			var (
				memo = []string{kv}
				j = k
			)
			for {
				// 根据记录信息拼接成key并写入记录与value的关联
				for i := j + 1; i < len(arr); i++ {
					tmp := make([]string, len(memo) + 1)
					copy(tmp, memo)
					tmp = append(tmp, arr[i])
					g.sign(value, tmp...)
				}

				if j++;j >= len(arr){
					break
				}
				memo = append(memo, arr[j])
			}
		}
	}

	return nil
}
// 标记
func (g *AttrGraph) sign(value string, str... string) {
	// 1对1的方式标记
	if len(str) == 1 {
		g.plats[str[0]][g.titleNodes[value].site] = 1
		return
	}
	// 组合标记
	key := strings.Join(str, "")
	_, ok := g.plats[key]
	if !ok {
		g.plats[key] = make([]byte, g.titleLen)
	}
	g.plats[key][g.titleNodes[value].site] = 1
	// 对组合元素同级标记
	for _, s := range str {

		if _,ok := g.titleNodes[s]; !ok  {
			continue
		}

		for _, v := range g.attrs[g.titleNodes[s].ahm] {
			g.plats[key][g.titleNodes[v].site] = 1
		}
	}
}

Import方法的作用是对传递的参数进行标记

实现流程

  1. 传递的量不足进行判断

  2. 须注意!!(Import参数中的attrs顺序与Screen中的参数 字段顺序必须要保持一致)

  3. 根据传递的属性关联字段进行标记,在标记的过程中会根据关联的字段进行组合然后再标记

  4. 在标记的细节上如果为多元组合的标记,除了对具体的关联标记外也会对每个元素的同级进行标记

在图的搜索实现中根据一个或多个的方式区分处理

// 搜索
func (g *AttrGraph) Screen(attrs ...string) []byte {
	attrs = g.sort(attrs)
	var memo []byte
	// 条件循环
	// 对单个选择的元素进行交集计算
	for _, v := range attrs {
		in, ok := g.intersectionWithGraph(v, memo)
		if !ok {
			return memo
		}
		memo = in
	}

	if len(attrs) == 1 {
		return memo
	}
	// 与组合元素是的交集
	key := strings.Join(attrs, "")
	in, ok := g.intersectionWithGraph(key, memo)
	if !ok {
		return memo
	}

	return in
}
// 与图内容元素交集
func (g *AttrGraph) intersectionWithGraph(key string, memo []byte) ([]byte,bool) {

	plats,ok := g.plats[key]
	if !ok {
		return memo, false
	}

	if len(memo) == 0 {
		return plats, true
	}
	// 计算与图中元素的交集
	in := make([]byte, g.titleLen)
	for k, v := range plats {
		if memo[k] == 1 && v == 1 {
			in[k] = 1
			continue
		}
		in[k] = 0
	}
	return in, true
}

实现的流程为

  1. 定义memo用于记录结果

  2. 如果只是传递一个参数则参数获取到对应值之后就会直接返回

  3. 当传递的参数具有多个属性时,则会先根据非组合情况下的属性进行筛选,然后再将多个属性组合查询并获取最终的交集结果进行返回

到此我们就实现完成最近下面是测试的用例代码

import "testing"

func TestAttrGraph_Import(t *testing.T) {

	graph := NewAttrGraph(Attrs(
		[]string{"4G", "5G"},
		[]string{"白色", "银色", "黑色"},
		[]string{"128G", "256G", "128G(无)", "256G(无)"},
	))


	type args struct {
		sku []string
	}
	tests := []struct {
		name    string
		args    args
		wantErr bool
	}{
		{"1", args{[]string{"4G","黑色", "128G"}}, false},
		{"2", args{[]string{"4G","白色", "128G"}}, false},
		{"3", args{[]string{"4G","黑色", "256G"}}, false},
		{"4", args{[]string{"4G","白色", "256G"}}, false},

		{"5", args{[]string{"5G","黑色", "128G"}}, false},
		{"6", args{[]string{"5G","银色", "128G"}}, false},
		{"7", args{[]string{"5G","黑色", "256G"}}, false},
		{"8", args{[]string{"5G","白色", "256G"}}, false},
		{"9", args{[]string{"5G","白色", "256G(无)"}}, false},
		{"10", args{[]string{"5G","银色", "128G(无)"}}, false},

	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if err := graph.Import(tt.args.sku); (err != nil) != tt.wantErr {
				t.Errorf("Import() error = %v, wantErr %v", err, tt.wantErr)
			}
		})
	}

	graph.Print()

	graph.Printf(graph.Screen("银色"))
	graph.Printf(graph.Screen("5G", "白色"))

}