【算法】如何分析一个排序算法

排序算法的执行效率

  1. 最好情况,最坏情况,平均情况时间复杂度
    第一,有些算法会区分,为了好对比,我们最好都要做一下区分。
    第二,对于要排序的数据,有的接近有序,有的是完全无序的。有序度的不同,对于排序的执行时间肯定有影响的。

  2. 时间复杂度的系数,常数,低阶
    时间复杂度一般都是数据规模n很大的时候的一个增长趋势,所以在这个时候它就会忽略系数,常数,低阶。但是我们一般排序的数据规模10,100,1000,这样很小的规模的数,所以我们要考虑系数,常数,低阶。

  3. 比较次数和交换(或移动)次数
    基本比较的排序算法,会设计两种操作。一种是比较两个数的大小,一种是交换两个数的位置。所以我们在分析排序算法的时候这两个都要考虑。

排序算法的内存消耗

算法的内存消耗可以通过空间复杂度来衡量,但是针对排序算法的时候,我们新加了一个概念,原地排序。原地排序就是特指空间复杂度为o(1)的排序算法

排序稳定性

针对排序算法还有一个很重要的指标,就是稳定性。这个概念就是说待排序的元素中有值相等的元素,在排序之后相等元素的先后顺序不会发生改变
发生改变的排序算法叫不稳定排序算法
不发生改变的排序算法叫稳定排序算法
为什么排序的稳定性比较重要呢?一般来说我们实际使用的时候,很有可能对一组数据做两次排序。如果排序算法不稳定的话,那我们第二次排序就会覆盖掉第一次排序所产生我们需要的结果。

[图形学]Unity Sharder入门精要(一)

前言

这一片文章写的是关于Unity Sharder入门精要第二章的读书笔记。

知识点

流水线

就是把一整套大步骤分为多个小步骤,然后可以一起执行。

渲染流水线

就是把三维场景中的内容渲染成一张二维图像。由CPU和GPU共同完成的。
这分为三个阶段:应用阶段,几何阶段,光栅化阶段

应用阶段:

这个阶段是由我们应用程序所主导的,因此通常由CPU负责实现。所以在这个阶段,我们开发者是有绝对的控制权。
三个主要任务:

  1. 准备场景数据
  2. 粗粒度的剔除工作
  3. 设置渲染状态
    场景数据:包括但不限于:摄像机位置,摄像机的视椎体,场景中的模型,光源
    粗粒度剔除工作:把那些完全不可见的物体剔除出去,这样就不需要再发给几何阶段出来,减少GPU工作。
    渲染状态设置:包括但不限制于:模型使用的材质,使用的纹理,使用的shader。
    在这个阶段重要的输出是渲染图元也就是渲染所需要的几何信息。这些图元被传递到下一个阶段-几何阶段
    三个阶段
  4. 把数据加载到显存中。
  5. 设置渲染状态
  6. 调用Draw Call
    把数据加载到显存中:渲染所有的数据都需要从硬盘加载到系统内存中,然后再把网格和纹理等数据又被加载到显存中。因为显卡对于显存的访问速度更快, 数据被加载好了以后,这些数据就可以被移除了,但是如果我们还需要做一些cpu的检测,那可以暂时不移除。
    设置渲染状态:渲染状态就是用来定义网格是被怎样渲染出来的,比如用了哪个定点着色器,用了什么光源属性,用了什么材质等等,如果我们没有更改渲染状态,那么所有的网格都讲使用同一种方法来进行渲染。
    调用Draw Call:Draw Call就是一个命令,从cpu发送到GPU,用来告诉GPU渲染哪个图元,这里面不包含任何材质信息,因为我们在前几步就已经把数据加载到了显存中了。当我们调用了Draw Call以后,GPU就会根据设置的渲染状态,和定点数据来进行计算,输出需要显示的像素。

    几何阶段

    这个阶段主要用于处理所有和我们要绘制的几何相关的事情,把从应用阶段保存到显存中的图元,在收到Draw Call指定的图元后,进行逐顶点,逐多边形的操作,在这个阶段有个最重要的任务就是把顶点坐标变换到屏幕空间中,然后再把这个坐标交到光栅器进行最后的处理。
    五个关键步骤

  7. 顶点着色器处理
  8. 曲面细分着色器处理
  9. 几何着色器处理
  10. 裁剪
  11. 屏幕映射处理
    顶点着色器
    这步是完全可编程的。当Draw Call通知需要被处理的图元时,这里就开始这一步,把图元的顶点进行变化,和着色。在这步我们是不能创建和销毁任何一个顶点,并且不能得到顶点和顶点间的关系,但是就是因为这样的相互独立性,所有我们可以利用GPU的特性进行并行化处理每一个顶点,这就意味着,处理顶点的速度将会变得很快。
    坐标转换:
    顶点着色器在这步改变顶点的位置,这个在顶点动画中是个非常有用的。需要注意的一点是,无论我们在顶点着色器中怎么改变顶点的位置,我们都需要把顶点坐标从模型空间转换到齐次剪裁空间,然后再得到归一化的设备坐标。
    曲面细分着色器
    这是一个可选的着色器,用于细分图元。
    几何着色器
    这也是一个可选着色器,可以被用来执行逐图元的着色操作,或者被用于产生更多的图元。
    裁剪
    用于裁剪那些不在摄像机视野方位内的顶点,并且剔除某些三角图元的面片,这一步是可配置的。一些图元的点有一部分在摄像机的视野外面,这个时候,我们就需要把摄像机视野外的点个裁剪掉,在和裁剪接触面来新建几个点,用来替换视野外的点。
    屏幕映射
    这一步是不可编程,也不可以配置的。这步负责把每个图元的坐标转换到屏幕坐标系中。这一步输入进来的坐标其实还是一个三维坐标,而我们的屏幕坐标系是一个二维坐标,所以我们要把图元显示出来,就得在这步来进行一次坐标转换,把三维坐标映射到屏幕坐标中,因为屏幕的坐标和屏幕的分辨率有很大的关系,所以这里将会进行一次矩阵计算。这步计算得到了顶点对应屏幕上的哪些像素以及距离这个像素有多远。这步只处理图元坐标中的x,y,对于z我们将保留原始值。只包含x,y的叫屏幕坐标系,加上一个z就是窗口坐标系。

    光栅化阶段

    把上一个阶段传递过来的数据产生屏幕上的像素,并渲染出最终的图案。主要的任务就是决定每个渲染图元中的哪些像素应该被绘制在屏幕上。它需要对上一个阶段得到的逐顶点数据进行插值,然后再进行逐像素处理。
    四个步骤

  12. 三角形设置
  13. 三角形遍历
  14. 片元着色器
  15. 逐片元操作
    三角形设置
    这个阶段计算光栅化一个三角网格所需要的所有信息。从上一个阶段输入进来的是三角网格的顶点,但是当我们要计算这个三角形所覆盖的像素情况时候,我们就需要计算每个边所对应的像素坐标。所以这步的最主要的功能就是为了能输出下一阶段所需要的三角网格的数据。
    三角形遍历
    这个阶段将会检查每个像素是否被一个三角网格所覆盖,如果被这个像素被覆盖了,那就生成一个片元,这样找到所有被三角网格所覆盖的过程就叫做三角形遍历(扫描变换)。使用三角网格3个顶点的顶点信息对整个覆盖区域的像素进行插值。这样输出的就是一个片元序列。这个片元不是真正意义上的像素,这里面包含了很多状态,这些状态就是用于计算像素最终颜色的。
    片元着色器
    这是一个很重要的可编程着色器阶段。其中很重要的一个技术就是:纹理采样。为了方便在这个阶段进行纹理采样,我们在顶点着色器阶段输出了每个顶点对应的纹理坐标。然后经过对三角网格的三个顶点对应的纹理坐标进行插值以后,就可以得到覆盖在片元上的纹理坐标。这步虽然可以完成很多重要的效果,但是它只能影响到一个片元,在这步里是不可以将自己的任何结果发送给其他片元。但是有个例外就是可以访问导数信息。
    逐片元操作
    这一阶段的主要目的就是合并。这一步也是一个高度可配置性。
    两个重要任务
  16. 决定每个片元的可见性
  17. 进行颜色合并
    在做片元可见性时候,我们需要对片元做一系列的测试。只有通过了所有的测试才可以进行颜色合并。如果没有通过测试,那么这个片元在前面所有的处理都是白费的,因为这个片元会被舍弃掉。有两个最基本的测试-深度测试和模板测试。
    模板测试
    模板测试相关的就是模板缓冲。开启以后,GPU会首先读取模板缓冲区中该片元位置的模板值,然后将该值和读取到的参考值进行比较。这个比较函数可以由开发者指定。如果片元没有通过就会被舍弃。不管片元有没有通过模板测试,我们都可以根据模板测试和下面的深度测试结果来修改模板缓冲区,这个操作也是由开发者决定的。
    深度测试
    如果开启了深度测试,那么gpu会把该片元的深度值和已经存在于深度缓冲区中的深度值进行比较。这个比较函数也是开发者可以设置的。如果没有测试通过,那么这个片元就会被舍去。这里有个地方和模板测试不一样的,就是如果没有通过测试那么这个片元就没有权利更改深度缓冲区中的值。如果通过了测试还可以通过开启/关闭深度写入来决定是否要用到这个片元的深度值覆盖掉原有的深度值。
    合并
    我们所讨论的渲染过程是一个物体接着一个物体画到屏幕上的。而每个像素的颜色信息被存储在一个名为颜色缓冲的地方。因此,当我们片元通过了所有测试将要被渲染出来的时候,颜色缓冲区中往往会有上一次渲染后的颜色结果,那么我们这次的片元的颜色将要怎么渲染呢?是覆盖还是其他处理,这里就需要的用合并来解决了。当如果是不透明的物体,我们就可以关闭混合,这样颜色值就会覆盖掉颜色缓冲区中的颜色。但是对于半透明的物体,我们就需要使用混合来让这个物体看起来是透明的,gpu会取出原颜色和目标颜色,将两种颜色进行混合,原颜色是从片元着色器中取出来的颜色值,目标颜色是存在与颜色缓冲区中的颜色值,之后使用一个混合函数来进行混合操作。这个混合函数通常跟透明通道息息相关。
    为了避免我们看到那些正在进行光栅化的图元,GPU会使用双重缓冲的策略。对于场景的渲染是在幕后发生的,就是后置缓冲。当场景已经被全部渲染到后置缓冲中,GPU就会交换后置缓冲区和前置缓冲区的内容。前置缓冲区就是我们所看到的图像。

    优化知识点

    一般在谈到优化,我们基本上都会考虑优化Draw Call这个属性,但是在我们这章的内容上,我们了解到Draw Call只是一个命令集,但是为什么我们要优化Draw Call的数量呢?这里我们需要考虑在发送Draw Call的之前CPU还做了什么操作,一个是加载数据,一个是设置渲染状态,加载数据的话,有人说是通用在内存上的交互,因为在Unity中我们读取AB包的时候,数据以及在内存上了,然后再从内存加载到显存上,但是在操作系统层面上,内存和显存的块都是在一个地方的,所以这步加载是没有什么消耗的。那最大的消耗就是在设置渲染状态了,看了下Unity官方的文档,Draw Call是资源密集型操作,主要开销是Draw Call之间的状态变化(列如切换到不同材质),而这种情况会导致图形驱动程序中执行密集型验证和转换步骤。所以我们减少的Draw Call的数量就是为了减少cpu上的消耗,导致卡顿。

[lua]关于lua中函数点和冒号的区别

前言

在lua中创建一个可以被外部访问的函数有两种方式,一种是table.function一种是table:function这两种函数的调用也是可以使用点和冒号两种方式调用的。

第一种使用table.function创建函数

local f = {}
function f.test(self,x,y) 
    print(self)
    print(x)
    print(y)
end
return f

使用点去访问

local f = require(f)
f.test(f,3,4)

打印出来的值是,self为f这个对象,x为3,y为4

使用冒号去访问

local f = require(f)
f:test(3,4)

打印出来的self还是这个对象,x为3,y为4

结论

当我们在创建一个方法的时候,我们时候点创建然后用冒号访问的时候,会默认把当前所被使用的对象给传进来。

第二种使用table:function创建函数

local f = {}
function f:test(x,y)
    print(self)
    print(x)
    print(y)
end
return f

使用点去访问

local f = require("test")
t.test(3,4)

打印出来的值为:3,4,nil;

local f = require("test")
t.test(f,x,y)

这个时候打印出来值为f,3,4

使用冒号去访问

local f = require("test")
t:test(3,4)

打印出来的是:f,3,4

结论

在这个测试中可以表明,使用冒号创建一个函数的时候,在使用点去调用的时候,会默认在第一个参数前加一个self的参数。

[Lua]关于lua元表(Metatable)

前言

在lua中最重要的一个数据结构就是table,而table有一个重要的功能就是设置元表(Metatable)在元表中设置元方法,就能帮我们实现面向对象的一些功能。

基础概念

lua中每个值都可以有一个元表,这个元表就是一个普通的table,它用于定义原始值在特定操作下的行为。如果你想改变一个值在特定操作下的行为,你可以在它的元表中设置对应域。
在元表的事件的键值是一个双下划线(__)加事件名的字符串;键关联的那些值被称为元方法。
可以用getmetatable函数来获取任何值的元表。lua使用直接访问的方式从元表中查询元方法。
可以使用setmetatable来替换一张表的元表,在lua中,你不可以改变表以外其他类型的值的元表;如果想改变这些非表类型的值的元表,需要是用c语言接口。
table和userdata有独立的元表,其他类型的值按类型共享元表;也就是说所有的数字都共享同一个元素,所有的字符串共享另一个元表等等。在默认情况下,值是没有元表的,但是字符串库在初始化的时候为字符串类型设置了元表。
元表决定了一个对象在数学运算,位运算,比较,连接,取长度,调用,索引时的行为。元表还可以定义一个函数,当table或者userdata被回收的时候调用它。

事件

  • add:+操作。如果任何不适数字的值做加法,lua都会尝试调用元方法。首先,lua检查第一个操作数,如果这个操作数没有为“add”事件的元方法,lua就会检查第二个操作数,一旦lua找到了元方法,它就将这两个操作数作为参数传入元方法里然后返回一个操作结果。如果找不到元方法,将抛出一个错误
local table1 = {
    [1] = 10
}
local table2 = {
    [1] = 20
}
--[
    a为+左边的,b为右边的值
--]
local mt = {
    __add = function(a,b)
        print(a[1],b[1])
    end
}
setmetatable(table2,mt)
local table3 = table1 + table2
  • __sub:-操作符。行为和“add”操作类型。

  • __mul:*操作符。行为和“add”操作类似。

  • __div:/操作符。行为和“add”操作类似。

  • __mod:%操作符。行为同上。

  • __pow:^(次方)操作符,行为同上

  • __unm:-(取反)操作,行为同上

  • __idiv://(向下取整除法)。行为同上

  • __band:&(按位与)操作。行为同上。lua会在任何一个操作数无法转换为整数时尝试取元方法

  • __bor:|(按位或)操作,行为同上。

  • __bxor:~(按位异或)操作,行为同上。

  • __bnot:~(按位非)操作,行为同上。

  • __shl:<<(左移)操作,行为同上。

  • __shr:>>(右移)操作,行为同上。

  • __concat:..(连接)操作,行为和“add”操作类似,不同的是lua在任何操作数即不是一个字符串,也不是一个数字的情况下尝试元方法。

  • __len:#(取长度)操作,如果对象不是字符串,lua会尝试它的元方法。如果有元方法,则调用它并将对象以参数形式传入,而返回值则作为结果。如果对象是一张表且没有元方法,lua使用表的取长度操作。其他情况均抛出异常

  • __eq:==(等于)操作。和“add”行为类似,不同的是lua仅在两个值都是表或都是userdata且他们不是同一个对象的时候才尝试元方法。返回结果转换为bool。

  • __lt:<(小于)操作。和“add”操作行为类似,不同的是lua仅在两个值不全为整数也不全为字符串时才尝试元方法。返回结果为bool。

  • le:<=(小于等于)操作。和其他操作不同,小于等于操作可能用到两个不同的事件。首先回去找两个操作数中的"le"元方法,如果都没有,那就会再次查找"__lt"。返回bool

  • __index:索引table[key]。当table不是表或者是表中不存在key这个键时,这个时间会被触发。这个事件的元方法可以是一张表,也可以是一个函数,如果是一个函数的话则以table和key作为参数调用它。如果是一个表,那就是在这个表里去key这个索引的值。

  • newindex:索引赋值table[key] = value.和索引事件类似,它发生在table不是表或是表table中不存在key这个键的时候调用对应的元方法
    这个方法可以是一个表也可以是一个函数,如果是函数的话则是以table,key和value作为参数传入。如果是一张表,则对这个表做索引赋值操作。一旦有了“
    newindex”元方法,lua就不再做最初的赋值操作。

  • __call:函数调用操作func(args)。当lua尝试调用一个非函数的值的时候会触发这个事件。查找func的元方法,如果找的到,func作为第一个参数传入,原来调用的参数(args)后依次排在后面。

方法

getmetatable(object)

这个方法是用来获取一个值的元表。否则,如果在该对象的元表中有“__metatable”域时返回其关联值,没有时返回该对象的元素。

setmetatable(table,metatable)

这个方法是用来设置一个值的元表。(不能在lua中改变其它类型的元表,只能在c中做),如果metatable是nil,则将table中的元表移除。如果元表有“__metatable”域,抛出一个错误。

rawequal(v1,v2)

在不触发任何元方法的情况下检查v1是否和v2相等,返回一个bool值

rawget(table,index)

在不触发任何元方法的情况下获取table[index]的值,table必须是一张表,index可以是任何值

rawlen(v)

在不触发任何元方法的情况下返回对象v的长度。v可以是表或字符串。它返回一个整数。

rawset(table,index,value)

在不触发任何元方法的情况下将table[index]设置为value.table必须是一张表,index可以使是nil与NaN之外的任何值。value可以是任何lua值。

实战

不能修改tale中的值

local list = {
    ["a"] = "a",
    ["b"] = "b"
}
local mt = {
    __index = list,
    __newindex = function (table,key,value)
        if list[key] then
            print("不能修改")
        end
        end,
}
local list2 = {}
local list1 = setmetatable(list2,mt)
list1["a"] = "c"

把list作为元表赋值给list1,当我要给list1赋值的时候,list1中表是空的,所以会去找元方法中的newindex,newindex函数的参数table代表着list2,key代表着“a”,value代表着“c”,做判断的时候,我们直接函数里面不处理,就不会添加到我们不想修改的list中的值了。

重点

有的时候,我们不想在lua中被设置了一个全局变量。因为lua其实也相当于一个表,它的全局变量存在_G中,首先我们可以先获取_G,然后给_G设置一个不能添加修改值的这个方法。

[算法]快排

原理

中心思想是用了二分法,首先取一个标致值,用这个标致值来分割两部分,一部分比它大,一部分比它小,然后就这样递归下去,直到全部的值都已经变成有序的。

上图


用图的方式更方便理解这个概念。

代码详情

第一种

 void Quicksort(int[]numbers,int left,int right)
            {
                if (left < right)
                {
                    int middle = numbers[(left + right) / 2];
                    int i = left -1 ;
                    int j = right + 1;
                    while (true)
                    {
                        while (numbers[++i] < middle) ;
                        while (numbers[--j] > middle) ;
                        if (i >= j)
                            break;
                        swap(numbers, i, j);
                    }
                    Quicksort(numbers, left, i - 1);
                    Quicksort(numbers, j+1, right);
                }
            }
void swap (int[]numbers,int i,int j)
            {
                int number = numbers[i];
                numbers[i] = numbers[j];
                numbers[j] = number;
            }

代码解释

首先我们找到一个标识值middle这个值是我们要分隔数组的中间值,然后我们定义两个下标,分别代表,比middle大的值,和比middle小的值,分别用i,j来表示,然后我们在while循环中,首先找到比middlle大的i下标的值,和比middle小的j下标的值,来调换位置,当找到i已经比j大等的时候,说明我们这次已经找完了,已经查到了middle这个值的下标,然后我们就开始分左右两个部分继续来大小划分。这时候的开始左边的区间就左边是这次的左区间加上这次找到的标识左边下标,右边区间就是标识右边的值加上当前区间的右边下标。

第二种

void Quicksort1(int [] numbers,int left,int right)
            {
                int i, last;
                if (left >= right)
                    return;
                swap(numbers, left, (left + right) / 2);
                last = left;
                for(i = left + 1; i < right; i++)
                {
                    if (numbers[i] < numbers[left])
                    {
                        swap(numbers, ++last, i);
                    }
                }
                swap(numbers, left, last);
                Quicksort1(numbers, left, last);
                Quicksort1(numbers, last+1, right);
            }
 void swap (int[]numbers,int i,int j)
            {
                int number = numbers[i];
                numbers[i] = numbers[j];
                numbers[j] = number;
            }

代码解释

首先我们把最左边的值和中间的值交换,然后我们记录下来交换的值的位置设置为last,因为我们已经把中间值和最左边的值交换了,所以我们标志值在最左边,我们就用这个值来和当前数组中的所有值来进行比较,如果比较的值比标识值小的话,就累加last然后交换,就把这个值换到了左边去了,如果大的话就不动,所以换过的左边都是比标识小的,最后当前的标识符换到最后一个最小的值的位置,那个位置之后就没有比标识值更小的了。然后再递归分割。

[Unity3D]Unity中影响渲染顺序的因素总结

一,Camera Depth

相机组件上设置的相机深度,深度越大越靠后渲染。

二,透明,不透明物体分隔

RenderQueue 2500是透明与不透明的分水岭。
同一个相机下
Renderqueue小于2500的物体始终在Renderqueue大于2500之前绘制。

三,Sorting Layer

在Tags&Layers设置中可见
如果Camera相同,那接下来就看Sorting Layers,越低越早绘制。
Sorting Layers是通过Renderer的SortingLayerName属性设置的。
全局的Soring Layers在Edit->ProjectSettings->Tags&Layers中设置可以通过代码设置render.sortingLayerName="Name"运行时设置物体的sortinglayer,如果在粒子系统中看,可以看到Render项的Sorting Layers下拉表单中会列出所有的Sorting Layers的名字。

四,Order In Layer

相对于Sorting Layer的子排序,用这个值做比较时只有Sorting Layer在同一层的时候才有效。
order是设置一个数字,数字越大,越显示在最上面。

五,RenderQueue

shader中对Tags设置的“Queue”。
如果上面几项都相同的情况下,就要设置RenderQueue了,renderQueue是Material的一个属性,其实就是shader中的renderQueue,这个值是一个int属性,数值越小,越靠近前面渲染。

六,深度排序。按照包围盒的深度进行排序

不透明物体由近到远排序优先
透明物体由远到进排序优先
按照包围盒的中心点的深度进行排序。

深度补间

ZBias,当两个包围盒中心点深度值相同或者很近的物体在一起时,排序有可能出错所以对于这个点的z值可以进行微调。

上图

[unity源码]AxisEventData

这个类是继承于BaseEventData是用于和轴相关的事件数据(控制器/键盘)

属性

  1. public Vector2 moveVector {get;set;}于此事件关联的原始输入向量

  2. public MoveDirection moveDir{get;set;} 此事件的MoveDirection类型一种表示左右上下none状态的一个枚举

方法

  1. public AxisEventData(EventSystem eventSystem):base(eventSystem) 一个带eventsystem的构造函数,初始化moveVector和moveDir属性的值,把参数传给基类BaseEventData

[Unity3D]编辑器类MenuItem方法浅析

前言

在使用Editor类型的时候,我们需要显示一个入口函数,一般来说,我们都是用MenuItem这个类来构造一个入口函数。

知识点

  1. itemName(string)
    这是用来显示我们在编辑器上所看到的item的名字,也可以认为是一个路径,用“/”来划分路径。
    当路径为Gameobject中的相应位置时候,还可以在Hierarchy中右键点出。
    当路径为“CONTEXT”后接组件名字的时候,是可以在组件的右键列表中添加按钮。

  2. validate(bool)
    这个是用来控制emunitem的可点击事件。一般来说我们都是不需要检测是够需要点击。但是有的时候当我们需要对一个object做判断,来规避一些错误。当这个object不是我们所需要的是object的时候,我们可以通过返回一个false来控制item的点击。示例代码:

    [MenuItem("GameObject/item",true,10)]
    static bool item(Menucommand menuCommand){
    if(menuCommand.context != null){
        return true;
    }else{
        return false;
    }
    }
    [MenuItem("GameObject/item",false,10)]
    static void item1(Menucommand menuCommand){
    Debug.Log(menuCommand.ToString());
    }

    如上所示,需要itemname为一致,配套使用,返回值为false的时候,item会灰掉不能点击。点击所走的方法为validate为false的。

  3. priority(int)
    当两个itemname的priority的值大于10的时候,将创建分割线。数值越大,越靠近列表的最下。相同itemname的调用的是priority最大的。

[c#基础]浅析c#Dictionary实现原理

引用原文 https://www.cnblogs.com/InCerry/p/10325290.html

前言

对于c#中的Dictionary类相信大家都不陌生,这是一个Collection(集合)类型,可以通过Key/Value(键值对)的形式来存放数据;改类最大的优点就是它查询元素的时间复杂度接近o(1),实际项目中常被用来做一些数据的本地缓存,提升整体效率。
那个是什么样的设计能让Dictionary类实现o(1)的时间复杂度呢?这就是这篇文章想和大家讨论的东西;这些都是个人的一些理解和观点。

理论知识

对于Dictionary的实现原理,有两个关键的算法,一个是hash算法,一个是用于对hash碰撞冲突解决算法。

1,hash算法

Hash算法是一种数字摘要算法,它能将不定长度的二进制数据集给映射到一个较短的二进制长度数据集,常见的MD5算法就是一种Hash算法,通过MD5算法可对任何数据生成数字摘要。而实现了Hash算法的函数我们叫它Hash函数。Hash函数又以下几点特征:

  1. 相同的数据进行Hash运算,得到的结果一定相同。
    HashFunc(key1) == HashFunc(key1);
  1. 不同的数据进行Hash运算,其结果可能会相同(Hash会产生碰撞).key1 != key2 => HashFunc(key1) == HashFunc(key2)

  2. Hash运算时不可逆,不能由key获取原始的数据.key => HashFunc(key)但是HashFunc(key)=\=> key

散列函数还需要满足以下性质:

  1. 从数据到整数值(0~N-1)的映射

  2. 足够分散

  3. 不易冲突

“足够分散”是指,即使数据只有很小的差异,散列函数的计算也需要很大的差异。
“不易冲突”是指,不易发生不同的数据得到相同的散列值的情况。()
由于散列值的计算和指定索引访问数组元素所需要的时间都和数据的个数无关,所以散列表的访问计算量为o(1)。
下图就是Hash函数的一个简单的说明,任意长度的数据通过HashFunc映射到一个较短的数据集中。

关于Hash碰撞,下图很清晰的就解释了,可从图中得知Sandra DeeJohn Smith通过Hashfunc运算后都落在了02的位置,产生了碰撞和冲突。

常见的构造Hash函数的算法有以下几种。

  1. 直接寻址法:取keyword或keyword的某个线性函数值为散列地址。即H(key)=key或H(key)= a*key+b,当中a和b是常数(这样的散列函数叫做自身函数)

  2. 数字分析法:分析一组数据,比方一组员工的出生年月日,这时候我们发现出生年月日的前几位数字大体相同,这样话,出现冲突的几率就会非常大,可是我们发现年月日的后几位和详细日期的数字区分非常大,假设用后面的数字来构成散列地址,则冲突的几率会明显减少。因此数字分析法就是找出数字的规律,尽可能利用这些数据来构造冲突几率较低的散列地址。

  3. 平方取中法:去keyword平方后的中间几位作为散列地址。

  4. 折叠法:将keyword切割成位数同样的几部分,最后一部分位数能够不同,然后取这几部分的叠加值和(去除进位)作为散列地址。

  5. 随机数法:选择一随机函数,取keyword的随机值作为散列地址,通常使用于keyword长度不同的场合。

  6. 除留余数法:取keyword被某个不大于散列列表表长m的数p除后所得的余数为散列地址。即H(key) = key MOD p,p<=m,不仅能够对keyword直接取模,也可在折叠,平方取中等运算之后取模。对p的选择非常重要,一般取素数或m,若p选的不好,容易产生碰撞。

2,解决冲突算法

对于一个hash算法,不可避免会产生冲突,那么产生冲突以后如果处理,这是一个很关键的地方,目前常见的冲突解决算法有链地址法(chain-ing),开放地址法(open addressing),再Hash法,公共溢出分区法,本文主要介绍链地址法(也就是c#的Dictionary所采用的),和开放地址法。

1. 链地址法

是将拥有相同散列值的元素存放在链表中,因此随着元素个数的增加,散列冲突和查询链表的时间也跟着增加,也就造成了性能的损失。优点:元素的删除可以用比较简单且搞性能的方式来实现。

2. 开放地址法

是在遇到冲突的时候,寻找一个新的数据存放空间(一般称为槽)。寻找空闲槽最简单的方法,就是循序遍历,直到找到空闲槽为止。
开放地址法中,在访问数据时候,如果散列值所代表的位置(槽)中不存在所希望的数据,则要么代表数据不存在,要么代表由于散列冲突而被转存到别的槽中了。于是通过以下算法来寻找目标槽位:

  1. 计算数据(key)的散列值。
  2. 从散列值找到相应的槽位(如果散列值比槽的数量大则取余数)
  3. 如果槽位与数据一致,则使用该槽->查找结束。
  4. 如果槽位为空闲,则散列表中不存在改数据->查找结束
  5. 计算下一个槽的位置。
  6. 返回第三步进行循环。

由于开放地址在数据存放上使用的是相对简单的数组方式,和链表相比较所需要的内存空间更小,因此在性能上存在有利的一面。
但是它的缺点也很明显,相比于原本的散列冲突发生的概率来说,它会让散列冲突发生的更加频繁。因此在这个方法里,会把有冲突的数据存放在下一个槽里,这就意味着下一个槽无法用来存放原本和散列值直接对应的数据。
当存放数据的数组逐渐被填满时,冲突的发生会十分频繁。当发生冲突以后,就必须通过计算来求得下一个槽的位置,用于槽查找的处理时间就会逐渐增加。因此,在开放地址法的设计中,所使用的数组大小必须是留有一定余量。
还有删除也是比较麻烦的,一般的元素和因冲突而不在原位的元素是混在一起的。因此无法简单地删除某个数据,要删除数据,仅仅将删除对象的数据所在槽置为空闲是不够的。
这样可能会把开放地址法中的连锁切断,从而导致本来存在的数据无法被找到。因此要删除数据,必须要将存放改元素的槽设置为一种特殊的状态,即“空闲(允许存放新数据)但不中断对下一个槽的计算”。

结论

随着散列表中存放的数据越来越多,发生冲突的危险性也随之增加。假设真有一种理想的散列函数,对于任何数据都能求出完全不同的散列值,那么当元素个数超过散列列表中槽的个数时,就不可避免地会产生冲突。尤其是开放地址法中当槽快要被填满时,所引发的冲突问题更加显著。
无论采用哪种方法,一旦发生冲突,就必须沿着某种连锁来寻找数据,因此无法实现o(1)的查找效率。
因此,在实用的散列表实现中,当冲突对查找效率产生的不利影响超过某一程度,就会对表的大小进行修改,从而努力在平均水平上保持o(1)的超早效率。
即便在最好的情况下,重组操作所需要的计算量也至少和元素的个数相关(即o(n)),不过,只要将重组的频率尽量控制在一个很小的值,就可以将散列表的平均访问消耗水平维持在o(1)。

三,Dictionary实现

Dictionary实现我们主要对着源码来解析,目前对照的版本为.NET Farmework 4.8,源码地址链接Dictionary
这一章主要介绍Dictionary中几个比较关键的类和对象,然后跟着代码来走一遍插入,删除和扩容的流程,相信大家就能理解它的设计原理。

1.Entry结构体

首先我们引入Entry这样一个结构体,它的定义如下代码所示。这是Dictionary类存放数据的最小单位,调用Add(Key,Value)方法添加的元素都会被封装在这样的一个结构体里。

private struct Entry{
    public int hashCode;        //哈希码的低31位,如果没有被使用则为-1
    public int next;            //下一个Entry元素的下标索引,如果没有下一个则为-1
    public TKey key;            //存放元素的key
    public TValue value;        //存放元素的value
}

2.其它关键私有变量

除了Entry结构体以外,还有几个关键的私有变量,其定义和解释如下代码所示:

private int[] buckets;                          //Hash桶
private Entry[] entries;                        //Entry数组,存放元素
private int count;                              //当前entries的index位置,当freeCount为0时启用
private int version;                            //当前版本,防止迭代过程中集合被更改
private int freeList;                           //被删除Entry在entries中的下标index,这个位置是空闲的
private int freeCount;                          //有多少个被删除的Entry,有多少个空闲的位置
private IEqualityComparer<TKey> comparer;     //比较器,并且为object生成Hashcode
private KeyCollection keys;                     // 存放key的集合
private ValueCollection values;                 //存放value的集合

上面代码中,需要注意的是buckets,entries这两个数组,这是实现Dictionary的关键。

3.Dictionary-Add操作

正常的Add操作

经过上面的分析,相信大家还是不是特别明白为啥需要这样的设计。现在让我们先走一遍Dictionary的Add流程,来体会一下。
首先我们用图的形容来描述一个Dictionary的数据结构,其中只画出了关键的地方。桶大小为4以及Entry大小也是4的一个数据结构。

然后我们假设需要一个Add操作,dictionary.Add("a","b"),其中key = "a",value = "b".
大致的流程如下:

  1. 根据key的值,计算出它的hashCode。我们假设“a”的hash值为6(comparer.GetHashCode(key)).计算出来的hashCode和0x7FFFFFFF按位与(&)取底31位值,计算出的值为当前key的hashcode,假设为6。

  2. 通过hashcode和buckets的长度去余,计算出改hashCode落在哪一个buckets桶中。现在桶的长度(buckets.Length)为4,那么假设取到的值为2(6%4)所以index的值就是2,也就是落在buckets[2]这个桶中。

  3. 避开一种情况不谈,接下来它会将hashCode,key,value等信息存入entries[count]中,因为count位置是空闲的,所以值是可以放进去的;继续count++指向下一个空闲位置。上图中第一个位置,index = 0就是空闲,所以就存放在entries[0]的位置。

  4. Entry的下标entryIndex赋值给buckets中对应下标bucket。步骤3中是存放在entries[0]的位置,所以buckets[2]=0

  5. 最后version++,集合发生了变化,所以版本需要+1.只有增加,替换和删除元素才会更新版本

实际步骤和上文的步骤会有些出入,现在这样区分只是为了能方便大家理解,后续将更详细的补充
完成上面Add操作后,数据结构更新成了下图这样的形式。

这样是理想情况下的操作,一个bucket中只有一个hashCode没有碰撞产生,但是实际上是会经常发生碰撞的;下面说下Dictionary类中是如果解决碰撞的。

碰撞的Add操作

我们继续执行一个Add操作,dictionary.Add("c","d"),假设根据上面的步骤1所得到的值还是6,那么求余数还是2,最后桶的index也还是2,按照以前的步骤执行完以后数据结构如下图所示

如果继续执行步骤4那么buckets[2] = 1,然后原来的buckets[2]=>entries[0]的关系就会丢失,这是我们不能接受的,所以在这个时候Entry中的next就开始发挥作用。
如果对应的buckets[index]有其它的元素已经存在,那么会执行以下两条语句,让新的entry.next指向之前的元素,让buckets[index]指向现在新的元素,就构成了一个单链表。

entries[index].next = buckets[targetBucket];
...
buckets[targetBucket] = index;

实际上步骤4也就是做一个这样的操作,并不会去判断是不是有其它元素,因为buckets中捅的初始值就是-1,所以直接赋值不会出现问题。
经过上面的步骤以后,数据结构就更新成了下面这个样子。

4.Dictionary-Find操作

为了方便演示如果查找,我们继续Add一个元素dictionary.Add("e","f"),comparer.GetHashCode("e")=7;7% buckets.Length=3,数据结构如下所示。

假设我们现在执行这样一条语句dictionary.GetValueOrDefault("a"),就会执行以下步骤

  1. 获取key的hashCode,计算出所在的桶的位置。我们之前提到过,“a“的hashcode = 6,所以计算出来targetBucket=2。

  2. 通过buckets[2]=1找到entries[1],比较key的值是否相等,相等就返回entryindex,不相等就继续entries[next]查找,直到找到key相等元素或者next==-1的时候,这里我们找到了key=="a"的元素,返回entryindex = 0.

  3. 如果entryindex >= 0 那么返回对应的entries[entryindex]元素,否则返回default(TValue)。这里我们直接返回entries[0].value.
    整个查找过程如下图

    代码如下

    //寻找Entry元素的位置
    private int FindEntry(TKey key){
    if(key == null){
        ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key);
    }
    if(buckets != null){
        int hashCode = comparer.GetHashCode(key)  & 0x7FFFFFFF; // 获取HashCode,忽略符号位
        // int i = buckets[hashCode % buckets.Length] 找到对应桶,然后获取entry在entries中位置
        // i >= 0; i = entries[i].next 遍历单链表
        for (int i = buckets[hashCode % buckets.Length]; i >= 0; i = entries[i].next) {
            // 找到就返回了
            if (entries[i].hashCode == hashCode && comparer.Equals(entries[i].key, key)) return i;
        }
    }
    return -1;
    }
    ...
    internal TValue GetValueOrDefault(TKey key){
    int i = FindEntry(key);
    // 大于等于0代表找到了元素位置,直接返回value
    // 否则返回该类型的默认值
    if (i >= 0) {
        return entries[i].value;
    }
    return default(TValue);
    }

    5.Dictionary-Remove操作

    前面介绍了增加,查找,接下来向大家介绍Dictionary是如何执行删除操作。我们沿用之前的Dictionary数据结构。

    删除前面的步骤和查找类似,也是需要找到元素的位置,然后再进行删除的操作。
    我们现在执行这样的一条语句dictionary.Remove("a"),hashFunc运算结果和上文中一致,步骤大部分与查找类似,我们直接看摘录的代码:

    public bool Remove(TKey key){
    if(key == null){
        ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key);
    }
    if(buckets != null){
         // 1. 通过key获取hashCode
        int hashCode = comparer.GetHashCode(key) & 0x7FFFFFFF;
        // 2. 取余获取bucket位置
        int bucket = hashCode % buckets.Length;
        // last用于确定是否当前bucket的单链表中最后一个元素
        int last = -1;
          // 3. 遍历bucket对应的单链表
        for (int i = buckets[bucket]; i >= 0; last = i, i = entries[i].next) {
            if (entries[i].hashCode == hashCode && comparer.Equals(entries[i].key, key)) {
                // 4. 找到元素后,如果last< 0,代表当前是bucket中最后一个元素,那么直接让bucket内下标赋值为 entries[i].next即可
                if (last < 0) {
                    buckets[bucket] = entries[i].next;
                }
                else {
                    // 4.1 last不小于0,代表当前元素处于bucket单链表中间位置,需要将该元素的头结点和尾节点相连起来,防止链表中断
                    entries[last].next = entries[i].next;
                }
                // 5. 将Entry结构体内数据初始化
                entries[i].hashCode = -1;
                // 5.1 建立freeList单链表
                entries[i].next = freeList;
                entries[i].key = default(TKey);
                entries[i].value = default(TValue);
                // *6. 关键的代码,freeList等于当前的entry位置,下一次Add元素会优先Add到该位置
                freeList = i;
                freeCount++;
                // 7. 版本号+1
                version++;
                return true;
            }
        }
    }
    return false;
    }

    执行完上面代码后,数据结构就更新成了下图所示,需要注意的是version,freelist,freecount的值都被更新了。

    6.Dictionary-Resize操作(扩容)

    有细心的小伙伴可能看过了Add的操作以后就会想了,buckets,entries不就是两个数组嘛,万一数组被放满了那怎么办?接下来就是我要说的Resize(扩容)这种操作了,对我们的buckets,entries进行扩容。

    6.1扩容操作的触发条件

    首先我们需要知道在什么情况下,会发生扩容操作:第一种情况自然就是数组已经满了,没有办法继续存放新的元素。如下所示

    从上文中大家也知道了,Hash运算会不可避免的产生冲突,Dictionary中使用链地址法来解决冲突的问题,但是如果一直都在冲突那就会如下所示:

    所有的元素都刚好落在了buckets[3]上面,结果就是导致了时间复杂度0(n),查找性能就会下降;所以第二种,Dictionary中发生碰撞的次数太多,导致严重的性能问题,也会触发扩容操作。
    **目前.Net Framwork 4.8中设置的碰撞次数阈值为100.

    public const int HashCollisionThreshold = 100;

    6.2扩容操作如何进行

    为了给大家演示清楚,模拟了以下的这种数据结构,大小为2的Dictionary,假设碰撞的阈值为2;现在触发Hash碰撞扩容

    开始扩容操作

  4. 申请两倍于现在大小的buckets,entries

  5. 将现有的元素拷贝到新的entries

完成上面的两步操作后,新的数据结构如下图所示。

  1. 如果是Hash碰撞扩容,使用新的HashCode函数来重新计算hash值

上文提到了,这是发生了Hash碰撞扩容,所以需要使用新的Hash函数计算Hash值,新的Hash函数并不一定能解决碰撞的问题,可能还会更糟糕,像下图中一样还是会落在同一个bucket
上。

  1. 对entries每个元素bucket=newEntries[i].hashCode%newSize确定新的buckets位置

  2. 重建hash链,newEntries[i].next=buckets[bucket];buckets[bucket]=i;

因为buckets也扩容了两倍大小,所以需要重新确定hashCode在哪个bucket中;最后重新建立hash单链表。

这就完成了扩容的操作,如果是达到Hash碰撞阈值触发的扩容可能扩容后结果会更差。
在JDK中,HashMap如果碰撞的次数太多了,那么会将单链表转换为红黑树提升查找性能,目前在.Net Framwork中还没有这样的优化,.Net Core中已经有了类似的优化。
每次扩容操作都需要遍历所有元素,会影响性能。所以创建Dictionary实例时最好设置一个预估的初始大小。

private void Resize(int newSize,bool forceNewHashCodes){
    Contract.Assert(newSize >= entries.Length);
    //1.申请新的Buckets和entries
    int[] newBuckets = new int[newSize];
    for(int i = 0;i<newBuckets.Length;i++) newBuckets[i] = -1;
    Entry[] newEntries = new Entry[newSize];
    //2.将entries内元素拷贝到新的entries内
    Array.Copy(entries,0,newEntries,0,count);
    //3.如果是Hash碰撞扩容,使用新的HashCode函数重新计算Hash值
    if(forceNewHashCodes){
        for(int i = 0; i<count; i++){
            if(newEntries[i].hashCode != -1){
                newEntries[i].hashCode = (comparer.GetHashCode(newEntries[i].key) & 0x7FFFFFFF);
            }
        }
    }
    //4.确定新的bucket位置
    //5.重建Hash单链表
    for(int i = 0;i < count; i++){
        if(newEntries[i].hashCode >= 0){
            int bucket = newEntries[i].hashCode % newSize;
            newEntries[i].next = newBuckets[bucket];
            newBuckets[bucket] = i;
        }
    }
    buckets = newBuckets;
    entries = newEntries;
}

Dictionary-再谈Add操作

在我们之前的Add操作步骤中,提到了这样的一句话,这里提到会有一种其它的情况,那就是有元素被删除的情况。

  1. 避开一种其它情况不谈,接下来它会将hashCode,key,value等信息存入entries[count]中,因为count位置是空闲的;继续count++指向下一个空闲位置。上图中第一个位置,index=0就是空闲位置,所以就存在entries[0]的位置。

因为count是通过自增的方式来指向entries[]下一个空闲的entry,如果有元素被删除了,那么在count之前的位置就会出现一个空闲的entry位置,代码如下:

private void Insert(TKey key,TValue value,bool add){
    if(key == null){
        ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key);
    }
    if(buckets == null)Initialize(0);
    //通过key获得hashCode
    int hashCode = comparer.GetHashCode(key) & 0x7FFFFFFF;
    //计算出目标bucket下标
    int targetBucket = hashCode%buckets.Length;
#if FEATURE_RANDOMIZED_STRING_HASHING
    //碰撞次数
    int collisionCount = 0;
#endif
    for(int i = buckets[targetBucket];i>= 0;i = entries[i].next){
        if(entries[i].hashCode == hashCode&&comparer.Equals(entries[i].key,key)){
            //如果增加操作,遍历到相同的元素,那么抛出异常
            if(add){
                ThrowHelper.ThrowArgumentException(ExceptionResource.Argument_AddingDuplicate);
            }
            //如果不是增加操作,那可能是索引赋值操作
            //赋值成功版本加一后退出
            entries[i].value = value;
            version++;
            return;
        }
#if FEATURE_RANDOMIZED_STRING_HASHING
        //遍历一次元素,都是一次碰撞
        collisionCount++;
#endif  
    }
    int index;
    //如果有被删除的元素,那么将元素放在被删除元素的空闲位置
    if(freeCount > 0){
        index = freeList;
        freeList = entries[index].next;
        freeCount--;
    }else{
        //如果当前的entries已满,那么出发扩容
        if(count == entries.Length){
            Resize();
            targetBucket = hashCode%buckets.Length;
        }
        index = count;
        count++;
    }
    //给entry赋值
    entries[index].hashCode = hashCode;
    entries[index].next = buckets[targetBucket];
    entries[index].key = key;
    entries[index].value = value;
    buckets[targetBucket] = index;
    //版本号加一
    version++;
    #if FEATURE_RANDOMIZED_STRING_HASHING
#if FEATURE_CORECLR
            // In case we hit the collision threshold we'll need to switch to the comparer which is using randomized string hashing
            // in this case will be EqualityComparer<string>.Default.
            // Note, randomized string hashing is turned on by default on coreclr so EqualityComparer<string>.Default will 
            // be using randomized string hashing
            //如果碰撞次数大于设置的最大碰撞次数,那么出发hash碰撞扩容
            if (collisionCount > HashHelpers.HashCollisionThreshold && comparer == NonRandomizedStringEqualityComparer.Default)
            {
                comparer = (IEqualityComparer<TKey>) EqualityComparer<string>.Default;
                Resize(entries.Length, true);
            }
#else
            if(collisionCount > HashHelpers.HashCollisionThreshold && HashHelpers.IsWellKnownEqualityComparer(comparer))
            {
                comparer = (IEqualityComparer<TKey>) HashHelpers.GetRandomizedEqualityComparer(comparer);
                Resize(entries.Length, true);
            }
#endif // FEATURE_CORECLR

#endif
}

现在在看完整的Add代码,是不是很简单了。

8.Collection版本控制

在上文中提到过一个version这个变量,在每一次增加,修改,删除的时候,都会对这个变量做一次递增;那么这个变量存在的意义是什么呢?
首先来看一段代码的运行,先实例化一个Dictionary的实例,然后通过foreach来遍历该实例,在foreach的代码块中使用remove(key)来删除元素

结果就是抛出来System.InvalidOperationException:"Collection was modified...这样的异常,迭代过程中不允许集合出现变化。这个就是用version来实现版本控制。
我们开看下源码是怎么实现版本控制的

[Serializable]
public struct Enumerator: IEnumerator<KeyValuePair<TKey,TValue>>,IDictionaryEnumerator{
    private Dictionary<TKey,TValue> dictionary;
    private int version;
    private int index;
    internal Enumerator(Dictionary<TKey,TValue> dictionary, int getEnumeratorRetType) {
        this.dictionary = dictionary;
        //这个给版本号赋值
        version = dictionary.version;
        index = 0;
        this.getEnumeratorRetType = getEnumeratorRetType;
        current = new KeyValuePair<TKey, TValue>();
    }
    public bool MoveNext() {
        //这里看版本是否有修改
        if (version != dictionary.version) {
            ThrowHelper.ThrowInvalidOperationException(ExceptionResource.InvalidOperation_EnumFailedVersion);
        }
    }
}

所以在迭代开始的时候记录一下version,后续在每一次迭代过程中都会去检查。

[读书笔记]具体数学1.1

知识点

  1. 稍加推广:先研究一个问题小的情况,通过尝试得到小问题的答案,然后思考大范围的问题

  2. 命名并求解:把大范围的问题具体化,设置一个变量名然后去解这个变量名的答案

  3. 递归式:给出一个边界值,以及一个用前面的值给出一般值的方程。

  4. 数学归纳法:证明某个命题对所有满足n \geq n_{0}的整数n都成立的一般方法。
    首先我们在n取最小值n_{0}时证明该命题,这一步骤称为基础;然后对
    n > n_{0}
    假设该命题对n_{0}与n-1之间(包含它们在内)的所有值都已经被证明,再证明该命题
    对n成立,这一步骤称为归纳。这样一种证明方法仅用有限步就得到无限多个结果。

  5. 实际证明使用三个阶段:
    (1) 研究小的情形,有助于我们洞察该问题
    (2)对有意义的量求出数学表达式并给出证明
    (3)对表达式求出封闭形式并给出证明
    递归式->递归解