数组、链表及其性能

猛戳链接阅读最终版

病人:医生医生,我一啪啪就蛋疼

医生:那就别啪

我在Twitter上说过:

你提醒过我使用 reduce 的方式构建数组虽然有趣,但有使性能减半的风险。

很多人觉得这句话很奇怪,这让我非常惊讶。相当一部分人建议将 reduce 版本改成不做数组拷贝的(我不认为这样是可行的)。也有建议说需要对 + 运算符做优化,让它不做拷贝操作(我同样不认为这样做很简单,而且很快我们就会意识到这一点)。

其他人建议我除非文档有提到,不然就不需要在意这些细枝末节的问题(而我认为这是在编写代码时必须注意的——说什么“只有在文档告诉我这里有问题时我才注意”就好像“只有单元测试结果显示不正确时我才编写正确代码”一样。)

而其他的反馈中,有一部分是与我之前发的链表那篇文章有关,为什么去实现一个已经过时的数据结构?我们已经有数组了,它的存在还有什么意义?

所以,你就知道为什么我有时候会特别提到这不是一个只关于 Mac 和 iOS 编程的博客?这当然不是一个只关于 Mac 和 iOS 编程的博客!不要因为我偶然觉得一个包含枚举类型的链表有趣你就把他放到你的 app 里面。因为我也可能会对你随之而来的性能问题产生兴趣,而你不会。尽管如此,我觉得链表的例子非常有意思,而且值得实现和把玩,它有可能为提升数组 reduce 方法的性能提供帮助。甚至在某些(少见的)场景下对实际编码有用。

同时我认为Swift的一些额外特性:比如它的枚举可以灵活滴在对象和具体方法中自由选择,以及“默认安全”。这些都促使它成为一门非常好的 CS 教学语言。这本书 未来的版本可能就会用Swift作为实现语言。

所以,总的来说——有时候,你会发现使用 reduce 来构建一个数组(字典或者集合),比如,下面是使用 reducemap 的实现:

extension SequenceType {
   func mapUsingReduce<T>(transform: Generator.Element->T) -> [T] {
        return reduce([]) { $0 + [transform($1)] }
    }
}

相对滴,创建可变数组然后使用 for 循环做映射的实现:

extension SequenceType {
   func mapUsingFor<T>(transform: Generator.Element->T) -> [T] {
        var result: [T] = []
        for x in self { result.append(transform(x)) }
        return result
    }
}

不同点在于, + 运算每次都会拷贝不断变长的数组。拷贝数组的消耗的时间是线性的,即遍历整个数组,所以随着映射数组长度的增长,消耗总时间成二次幂增长。

尽管如此,正常来说人们都不会去重新实现 map 函数:你看到的更多的会是这样一些技巧:告诉你去重或者根据词频建立字典。但是最本质的问题仍然存在。

但是这个和链表又有什么关系?因为你可以利用 上次链表的代码 来实现使用 reducemap 版本,就像下面这样:

extension SequenceType {
    func mapToList<T>(transform: Generator.Element->T) -> List<T> {
        return reduce(List()) { $0.cons(transform($1)) }.reverse()
    }
}

结果就是这个版本的性能竟然只有数组版本的一半(因为 reverse 这一步),以至于你的老师都会怀疑你是在测试结果上作弊,而不是实验产生的结果:

得到这个结果的原因是链表是连续的——旧的链表和新连接的链表之间永远都是用节点相连。所以不需要拷贝。但是代价是只能从头部增加链表的长度(所以才需要 reverse),而且链表必须是完全不变的。所以就算链表只有一个引用的时候,也需要先拷贝再对它进行修改。这和 Array 是有区别的, Array 可以检测它的缓冲区何时是单独被访问的,然后就可以直接修改,而不需要拷贝。使用链表还有其他的代价——统计链表节点的个数所需要的时间是统计数组元素个数时间的两倍,因为遍历链表时的间接寻址方式是需要消耗时间的。

所以数组在 + 操作时做完全拷贝的问题是可以解决的么?在考虑这个问题之前,我们先来看一个写时拷贝数组能有什么帮助。Mike Ash 的 一篇牛x博文 已经实现了一个写时拷贝数组,所以我们稍作改变,使用标准库中的 ManagedBuffer 类来实现写时拷贝数组。

ManagedBuffer

ManagedBuffer 是一个可继承,用于简化分配/释放内存操作和堆上内存管理的类。它是泛型,有 ValueElement 这两个独立占位符, Element 存储了 n 个元素的 block 所属的类型,它在创建实例时就被动态分配出来。Value 则是附加用来存储其他信息的变量所属的类型——比如,如果要实现数组,你需要存储元素的个数,因为在内存释放之前需要把元素销毁掉。对元素的访问是通过 withUnsafeMutablePointerToElements ,而对 value 的访问则可以通过一个简单的非安全方法,或者直接使用 .value 属性。

下面的代码实现了一个极简的自销毁数组缓冲区:

private class MyArrayBuffer<Element>: ManagedBuffer<Int,Element> {
    deinit {
        self.withUnsafeMutablePointerToElements { elems->Void in
            elems.destroy(self.value)
        }
    }
}

这样一来, MyArrayBuffer 存储的元素仍然是泛型, 但是把 ManagedBufferValue 设置为 Int, 用来保存缓冲区元素的个数(有一点需要铭记于心,我们会分配比数组中元素更多的空间,用来避免频繁的重分配操作)。

当缓冲区被析构时,MyArrayBuffer.deinit 会在 ManagedBuffer.deinit 之前调用,ManagedBuffer.deinit 会释放内存。这样的话 MyArrayBuffer 就有机会销毁其所有对象。如果 Element 不单单是一个被动的结构体,销毁对象就非常有必要了——比如,如果数组里面包含了其他写时拷贝类型,销毁它们会触发它们去释放自身的内存。

现在我们可以建立一个数组类型的结构体,这个结构体使用一个私有的缓冲区来进行存储:

public struct MyArray<Element> {
    private var _buf: MyArrayBuffer<Element>

    public init() {
        _buf = MyArrayBuffer<Element>.create(8) { _ in 0 } as! MyArrayBuffer<Element>
    }
}

我们不直接使用 MyArrayBufferinit——而使用 ManagedBuffer 的类方法。因为这个方法返回父类,然后我们将其向下强制转换为正确的类型。

然后我们将 MyArray 变成一个集合类型:

extension MyArray: CollectionType {
    public var startIndex: Int { return 0 }
    public var endIndex: Int { return _buf.value }

    public subscript(idx: Int) -> Element {
        guard idx < self.endIndex else { fatalError("Array index out of range") }
        return _buf.withUnsafeMutablePointerToElements { $0[idx] }
    }
}

下一步,我们需要为缓冲区添加两个相当相似的方法,一个用来拷贝内存,另一个用来调整内存大小。拷贝方法会在有检测到共享存储时调用,调整大小方法则会在需要更多内存时调用:

extension MyArrayBuffer {
    func clone() -> MyArrayBuffer<Element> {
        return self.withUnsafeMutablePointerToElements { oldElems->MyArrayBuffer<Element> in
            return MyArrayBuffer<Element>.create(self.allocatedElementCount) { newBuf in
                newBuf.withUnsafeMutablePointerToElements { newElems->Void in
                    newElems.initializeFrom(oldElems, count: self.value)
                }
                return self.value
            } as! MyArrayBuffer<Element>
        }
    }

    func resize(newSize: Int) -> MyArrayBuffer<Element> {
        return self.withUnsafeMutablePointerToElements { oldElems->MyArrayBuffer<Element> in
            let elementCount = self.value
            return MyArrayBuffer<Element>.create(newSize) { newBuf in
                newBuf.withUnsafeMutablePointerToElements { newElems->Void in
                    newElems.moveInitializeFrom(oldElems, count: elementCount)
                }
                self.value = 0
                return elementCount
            } as! MyArrayBuffer<Element>
        }
    }
}

同时构建和填充缓冲区是有些苛刻的——首先我们需要获得指向已存在元素的非安全指针,然后调用 create,这个方法拥有的闭包会接收一个只构建了一部分的对象(比如,分配了内存但是没有初始化),这个对象随后需要调用 newBuf.withUnsafeMutablePointerToElements 来把内存从旧的缓冲区拷贝到新的缓冲区。

这两个方法最主要的不同点是 clone 不会改变旧的缓冲区中的元素,而只是把新的拷贝加载到新的缓冲区。 resize 则会把元素从旧的内存移动到新的内存(通过 UnsafeMutablePointermoveInitializeFrom 方法),然后更新旧的缓冲区,告诉它已经不需要管理任何元素——不然,它会试图在 deinit 时销毁它们。

最后,我们给 MyArray 添加一个 appendextend 方法:

extension MyArray {
    public mutating func append(x: Element) {
        if !isUniquelyReferencedNonObjC(&_buf) {
            _buf = _buf.clone()
        }

        if _buf.allocatedElementCount == count {
            _buf = _buf.resize(count*2)
        }

        _buf.withUnsafeMutablePointers { (val, elems)->Void in
            (elems + val.memory++).initialize(x)
        }
    }

    public mutating func extend<S: SequenceType where S.Generator.Element == Element>(seq: S) {
        for x in seq { self.append(x) }
    }
}

这只是一段样例代码。事实上,你可能会把唯一性判断代码和调整大小代码单独抽出来,这样你就可以在下标集和其他稍微有变化的方法中重用,而我只是简单起见所以就把他们都塞在 append 方法里面了。而且有可能的话你会为 append 保留足够的空间让它进行扩展,这样就可以防止在同时共享且空间太小时缓冲区被双重拷贝。但是所有这些事情对于我们的伟大蓝图都没有太大的影响。

好了现在就是操作符了。首先, += ,赋值操作符,它左侧是 inout 的,使用右侧的内容对左侧进行扩展:

func +=<Element, S: SequenceType where S.Generator.Element == Element>
  (inout lhs: MyArray<Element>, rhs: S) {
    lhs.extend(rhs)
}

最后是 + 操作符。我们可以根据 += 操作符的方式来实现它。这个操作符传入两个不可变的数组,然后将它们合并成一个新的数组。它依赖于写时拷贝动作来为左侧内容创建一个可变拷贝,然后使用右侧的内容来进行扩展:

func +<Element, S: SequenceType where S.Generator.Element == Element>
  (lhs: MyArray<Element>, rhs: S) -> MyArray<Element> {
    var result = lhs
    result += rhs
    return result
}

事实上你可以在 lhs 变量之前使用 var 标识符来进一步缩短代码:

func +<Element, S: SequenceType where S.Generator.Element == Element>
  (var lhs: MyArray<Element>, rhs: S) -> MyArray<Element> {
    lhs += rhs
    return lhs
}

我提到第二个版本是因为有人提议一个更好的 reduce 策略可能是为累加参数上添加 var 标识符。而这刚好和 lhs 所发生的事情是类似的: var 所做的事情只是声明传入的参数是可变的。它仍然是拷贝——它不是以某种方式传递过来原值的引用。

+ 操作符是可以优化的么?

现在我们有了一个完全可用的写时拷贝数组的雏形,你可以对它做 append 操作,它也实现了 + 操作符。这也就意味着我们可以用它来重写 reduce 版的 map 方法:

func mapUsingMyReduce<T>(transform: Generator.Element->T) -> MyArray<T> {
    return reduce([]) { $0 + [transform($1)] }
}

func mapUsingMyFor<T>(transform: Generator.Element->T) -> MyArray<T> {
    var result = MyArray<T>()
    for x in self { result.append(transform(x)) }
    return result
}

如果你用图表对性能进行记录,你会发现这两段代码和使用数组实现的方式的表现完全类似。

所以,现在的情况是我们拥有一个完全受我们自己控制的实现,我们可以改变 + 操作符然后让它不做拷贝么?我不认为我们做到了。

来看一个更简单的例子,我们可以改变下面的代码:

var a = MyArray<Int>()
a.extend(0..<3)
let b = a + [6,7,8]

然后让它不做拷贝么?很明显我们不能。 b 必须是一个新的数组拷贝,目的是不影响 a。即使我们在创建 b 之后不对 a 做任何修改, + 操作符的实现也是没有办法知道这些的。也许编译器会知道,然后根据情况进行优化,但是 + 方法是不可能知道的。

在此处检查唯一引用也不会有什么帮助。 a 仍然存在,所以 lhs 不可能是缓冲区的唯一持有者。

reduce 方法也没什么不同,下面是一种可能的实现:

extension SequenceType {
    func myReduce<T>(initial: T, combine: (T,Generator.Element)->T) -> T {
        var result = initial
        for x in self {
            result = combine(result,x)
        }
        return result
    }
}

假设这里的 combine{ $0 + [transform($1)] },你会发现 + 操作符同样不知道我们直接将结果赋值给了 result 变量。检查代码我们就知道,如果有可能的话把右侧的内容添加到左侧的内容中是可行的(理论上来说是的,因为尽管数组是以不可变的值传递的,因为它的缓冲区是一个类,这样它就有了引用的语义,从而可以被改变)。但是 + 操作符单通过它的位置是不可能知道这点的。它只是明确滴知道左侧内容的拷贝不是缓冲区唯一的持有者。还有另外一个持有者:reduce 持有 result 的一份拷贝——而且马上就要将其摒弃然后使用新的结果来替换它,但是这都是在 + 操作执行之后

还有一线希望就是如果每个数组刚好是它们的分片(然而并不是——而是有一个叫 ArraySlice 的东西,它需要额外的开销来把分片的起始和结束点记录到父数组中)。如果它们是的话,也许它们就可以被修改成允许其中一个,也只能是一个数组在做 append 操作时被其他数组忽略。但是这通常都可能会增加数组的开销,而整个数组的目的就是要快速——你肯定不会为了这种情况就让它们变慢吧。

也许有一种非常聪明的办法可以解决所有的这些问题,可能需要编译器的帮助也可能不需要。但尽管如此这仍然不是一个很好的主意。+ 操作符语义是创建一个新数组。而想让它在某种非常特定情况下隐式滴修改一个已经存在的数组显然不是正确解决方案。如果有需要的话你可以把 var 封装在一个小的虚方法中,就好像没有使用它一样。但是它仍然可以让你的代码效率变得更高。

时间: 2024-10-11 04:54:41

数组、链表及其性能的相关文章

jdk1.8 HashMap 实现 数组+链表/红黑树

转载至 http://www.cnblogs.com/leesf456/p/5242233.html 一.前言 在分析jdk1.8后的HashMap源码时,发现网上好多分析都是基于之前的jdk,而Java8的HashMap对之前做了较大的优化,其中最重要的一个优化就是桶中的元素不再唯一按照链表组合,也可以使用红黑树进行存储,总之,目标只有一个,那就是在安全和功能性完备的情况下让其速度更快,提升性能.好~下面就开始分析源码. 二.HashMap数据结构 说明:上图很形象的展示了HashMap的数据

Java算法入门-数组&amp;链表&amp;队列

算法就是解决问题的步骤,在一般的项目中可能用不上,但是一旦涉及到高并发,高性能时,就不得不考虑算法的优劣. 设计原则和特性 设计原则:正确性,可读性,健壮性,高效率和低存储 特性:有穷性,确定性,可行性,有输入,有输出. 算法题入门体验 如何判断是一个数是2的整数次幂?常规方法使用循环,但是在学习了二进制的基础知识后,发现可以使用模运算来实现. 1 import java.util.Scanner; 2 3 public class base { 4 5 public static void m

javascript 三种数组复制方法的性能对比

javascript 三种数组复制方法的性能对比,对于webkit, 使用concat; 其他浏览器, 使用slice. 一. 三种数组复制方法 1. by slice var arr = [1, 2, 3], copyArr; copyArr = arr.slice(); 2. by concat var arr = [1, 2, 3], copyArr; copyArr = arr.concat(); 3. by loop var arr = [1, 2, 3], copyArr = [];

数据结构java(一)数组链表

链表是数据结构中最基础的内容,链表在存储结构上分成两种:数组形式储存,链式存储. 相比c语言需要的结构体,在java中由于有了面向对象编程,将指针'藏'了起来,不需要分配内存. 所以只需要创建一个对象数组,为了能让链表更加实用,方便存储非基本类型的对象,所以使用了泛型. 菱形运算符<>中放你自己写的或者基本类型,比如你创建了一个Stdent类,想用链表将很多学生的信息存起来. 就可以myArrayList<Student> a=new myArrayList();这个链表就都存St

树的存储方式数组链表+vector

图的邻接矩阵存储法,它的空间和时间复杂度都是N2,现在我来介绍另外一种存储图的方法:邻接表,这样空间和时间复杂度就都是M.对于稀疏图来说,M要远远小于N2 模板如下: struct edge{ int u,v,w,next; }edge[N*3]; int first[N],t; void init() { t = 0; memset(first,-1,sizeof(first)); } void add(int u,int v,int w) { edge[t].u = u;     //u:第

JavaScript使用数组拼接字符串性能如何?

传统上,字符串连接一直是js中性能最低的操作之一. view source print? 1 var text="Hello"; 2 text+=" World!"; 早期浏览器没有对这种运算进行优化.由于字符串是不可变的,这意味着要创建中间字符串来存储连接的结果.频繁地在后台创建和销毁字符串导制性能异常低下. 发现这一点后,开发者们利用数组对象进行优化.句容市鄂茂钢铁 view source print? 1 var buffer=[],i=0; 2 buffer

多维数组的遍历性能

数组是Java中的一种容器对象,它拥有多个单一类型的值.当数组被创建的时候数组长度就已经确定了.在创建之后,其长度是固定的.下面是一个长度为10的数组: public class ArrayDemo { private int arraySize=10; public int[] arrayOfIntegers = new int[arraySize]; } 上面的代码是一维数组的例子.换句话说,数组长度只能在一个方向上增长.很多时候我们需要数组在多个维度上增长.这种数组我们称之为多维数组.为简

数据结构:快状链表(数组链表联合)

#include <iostream> #define _MAX_ 10 using namespace std; //块状链表. struct MyNode { //按理说每块数据的数组长度应该是根号N到2倍的根号N之间,暂时 //我为了测试就使用100个左右的数据. int *data; int size;//大小. int currentIndex;//当前下标. MyNode *prev; MyNode *next; MyNode() :prev(NULL), next(NULL) {

js数组去重与性能分析(时间复杂度很重要)

随着js的深入和实际项目对性能的要求,算法的简单实现已经不能满足需要,在不同的应用场景下,时间复杂度很重要. 首先是创建数组与性能处理函数: // 保证时间差的明显程度,创建不同量级的数组,size为数组的量级 function createArr (size) { var len = size/100 var create = [] for (var i=0; i<100; i++ ) { for(var j=0; j<len; j++) { create.push(j) } } retur