Android 中的ArrayList & LinkedList

前言

通过之前的《数据表-线性结构》。我们已经了解了数据结构中线性表这种存储结构的特点,并就其顺序存储和链式存储的优缺点,及实现方式做了深入的分析,并做了简单的实现;这里我们就从日常开发中常用的ArrayList和LinkedList出发,巩固学习一下线性表这种数据结构。

下面就从线性表的ADT出发,结合其构造函数,常用的增删改查方法的实现,比较一下两种存储结构的特点。

以下源码内容源自于Android SDK API(api-level 25),部分方法的实现和普通的Java JDK中的实现会有一些出入;日常开发中,API的实现必然是以Android SDK 为主,所以还是从Android SDK 内自带的实现出发分析

ArrayList

ArrayList本质上就是一个动态数组。

初始化及构造函数

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
    private static final long serialVersionUID = 8683452581122892189L;
    private static final int DEFAULT_CAPACITY = 10;
    private static final Object[] EMPTY_ELEMENTDATA = {};
    transient Object[] elementData;
    private int size;
    public ArrayList(int initialCapacity) {
        super();
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal Capacity: "+
                                               initialCapacity);
        this.elementData = new Object[initialCapacity];
    }
    public ArrayList() {
        super();
        this.elementData = EMPTY_ELEMENTDATA;
    }
    public ArrayList(Collection<? extends E> c) {
        elementData = c.toArray();
        size = elementData.length;
        // c.toArray might (incorrectly) not return Object[] (see 6260652)
        if (elementData.getClass() != Object[].class)
            elementData = Arrays.copyOf(elementData, size, Object[].class);
    }
}

构造函数的实现,很容易理解;这里需要注意以下,我们可以使用其他的集合初始化ArrayList,本质上就是把一个集合中的内容复制到一个数组中。c.toArray实现了列表集合到数组的转换,Arrays.copyOf就是数组的拷贝。当然,这个过程不一定会成功,当失败的时候,会由Arrays.copyOf内部抛出NullPointerException。

增、删、改、查 的实现

既然是一个动态数组,那么他是怎样实现动态增长的呢?


    public boolean add(E e) {
        ensureCapacityInternal(size + 1);  // Increments modCount!!
        elementData[size++] = e;
        return true;
    }
    private void ensureCapacityInternal(int minCapacity) {
        if (elementData == EMPTY_ELEMENTDATA) {
            minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
        }

        ensureExplicitCapacity(minCapacity);
    }

    private void ensureExplicitCapacity(int minCapacity) {
        modCount++;

        // overflow-conscious code
        if (minCapacity - elementData.length > 0)
            grow(minCapacity);
    }

    /**
     * The maximum size of array to allocate.
     * Some VMs reserve some header words in an array.
     * Attempts to allocate larger arrays may result in
     * OutOfMemoryError: Requested array size exceeds VM limit
     */
    private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;


    private void grow(int minCapacity) {
        // overflow-conscious code
        int oldCapacity = elementData.length;
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        if (newCapacity - minCapacity < 0)
            newCapacity = minCapacity;
        if (newCapacity - MAX_ARRAY_SIZE > 0)
            newCapacity = hugeCapacity(minCapacity);
        // minCapacity is usually close to size, so this is a win:
        elementData = Arrays.copyOf(elementData, newCapacity);
    }

    private static int hugeCapacity(int minCapacity) {
        if (minCapacity < 0) // overflow
            throw new OutOfMemoryError();
        return (minCapacity > MAX_ARRAY_SIZE) ?
            Integer.MAX_VALUE :
            MAX_ARRAY_SIZE;
    }

可以看到,对于数组的大小何时改变,如何改变,有着非常严格的规则。

  • 对于空数组,第一次添加元素时,数组大小将变化为 DEFAULT_CAPACITY,也就是10的大小,add操作,总是将元素添加到数组最后。
  • 当我们不断添加元素,动态数组的size大于DEFAULT_CAPACITY时,例如我们添加了第11个元素,此时在grow方法中,
        int oldCapacity = elementData.length;
        int newCapacity = oldCapacity + (oldCapacity >> 1);

最终,newCapacity 的值为elementData.length的1.5倍,也就是15,因此,此时数组大小将变化为15。也就是说,正常情况下,ArrayList每次扩容,都将会在原来的基础上,增加50%的大小。

  • 如果数组不断的增长,当我们按增长50%的规律扩容后,如果newCapacity大于MAX_ARRAY_SIZE时,此时数组最大的长度为Integer.MAX_VALUE。


    public E remove(int index) {
        if (index >= size)
            throw new IndexOutOfBoundsException(outOfBoundsMsg(index));

        modCount++;
        E oldValue = (E) elementData[index];

        int numMoved = size - index - 1;
        if (numMoved > 0)
            System.arraycopy(elementData, index+1, elementData, index,
                             numMoved);
        elementData[--size] = null; // clear to let GC do its work

        return oldValue;
    }

相较于添加一个元素来说,删除一个特定位置的元素就简单多,按照位置关系把数组元素整体(复制)移动一遍,然后将特定位置的引用指向null即可;当然,也可以按照元素的值,从数组中移除元素,或者清空数组,本质上都是一样的工作,就不赘述了。

通过以上内容可以看到,为了方便实现数组的复制操作,这里Arrays.copy 和System.arraycopy 方法,Arrays.copy 在上一篇Java工具类之Arrays梳理中已经介绍过了,System.arraycopy是其内部实现。

改 & 查

    public E set(int index, E element) {
        if (index >= size)
            throw new IndexOutOfBoundsException(outOfBoundsMsg(index));

        E oldValue = (E) elementData[index];
        elementData[index] = element;
        return oldValue;
    }

    public E get(int index) {
        if (index >= size)
            throw new IndexOutOfBoundsException(outOfBoundsMsg(index));

        return (E) elementData[index];
    }

由于实现了RandomAccess接口,因此随机访问数组元素是一件特定容易的事情,修改变得so easy,根据数组下标赋值操作,小学生都看得懂了。因此,可以看到对ArrayList,修改和查找是一件十分容易的事情。

批量操作

批量添加

对于动态数组的批量添加操作,和单个元素的操作是没有多少区别的,无非就是进行扩容操作,复制和移动数组元素时,确定好各自的位置就可以了

    public boolean addAll(int index, Collection<? extends E> c) {
        if (index > size || index < 0)
            throw new IndexOutOfBoundsException(outOfBoundsMsg(index));

        Object[] a = c.toArray();
        int numNew = a.length;
        ensureCapacityInternal(size + numNew);  // Increments modCount

        int numMoved = size - index;
        if (numMoved > 0)
            System.arraycopy(elementData, index, elementData, index + numNew,
                             numMoved);

        System.arraycopy(a, 0, elementData, index, numNew);
        size += numNew;
        return numNew != 0;
    }

在ArrayList的特定位置,添加一组数据,可以看到关键点都是数组元素的复制。这个时候ensureCapacityInternal的参数,就有可能是一个非常大的值,参考前面add()方法中的扩容操作,这种情况下,一次性可能需要将数组扩大很多。

批量移除

对于批量移除,ArrayList提供了两个使用的方法:

    public boolean removeAll(Collection<?> c) {
        return batchRemove(c, false);
    }

从当前的ArrayList集合中移除所有包含在集合c中的元素。

    public boolean retainAll(Collection<?> c) {
        return batchRemove(c, true);
    }

从当前的ArrayList集合中移除所有不在集合c中的元素,换句话说,就是保留两个集合中共有的元素。

可以说,这两个方法实现的操作是互斥的。而他们内部实现都是batchRemove,我们可以看一下。

private boolean batchRemove(Collection<?> c, boolean complement) {
        final Object[] elementData = this.elementData;
        int r = 0, w = 0;
        boolean modified = false;
        try {
            for (; r < size; r++)
                if (c.contains(elementData[r]) == complement)
                    elementData[w++] = elementData[r];
        } finally {
            // Preserve behavioral compatibility with AbstractCollection,
            // even if c.contains() throws.
            if (r != size) {
                System.arraycopy(elementData, r,
                                 elementData, w,
                                 size - r);
                w += size - r;
            }
            if (w != size) {
                // clear to let GC do its work
                for (int i = w; i < size; i++)
                    elementData[i] = null;
                modCount += size - w;
                size = w;
                modified = true;
            }
        }
        return modified;
    }

这里使用contains方法,实现了元素的筛选。根据if语句,只有当complement为true时,也就是执行retainAll()方法时,w才会累加;需要注意的是,如果两个集合中的元素不兼容,contains()方法会抛出异常,因此会导致循环提前跳出。

最终都会执行到finally语句,在这里

  • 当运行retainAll()方法,循环正常执行完毕时,w=r=size;两个if 判断都不会执行,恰好保存了两个集合中共有的元素;当循环操作异常跳出时,w=r<size;第一个if 判断执行,会把后续所有的元素按照位置复制一遍,w+=size-r 后,w=size,后一个if 不执行。
  • 当运行removeAll()方法,循环正常执行完毕时,r=size,w=0;后一个if 判断中,通过循环所有元素置null,恰好实现了所有元素清空的操作;当循环异常跳出时,r<size,w=0;最终只有size-r 个元素被置为null,没有所有元素清空的操作。

可以看的,这个算法的实现很是巧妙。好了,ArrayList的内容,就告一段落,下面看看LinkedList。

LinkedList

《数据表-线性结构》中我们说过,对于数据的链式存储结构,有单链表,循环链表,双向链表,静态链表四种实现,而LinkedList 就是以双向循环链表双向链表的方式实现。

这里需要注意的是,这里的LinkedList是双向链表,但并不是首位相连接的循环链表

为了方便,我们可以看看下面这幅图,回想一下一个双向链表有哪些关键点,如何实现它的增删改查操作。

双向链表.jpg

初始化及构造函数

java 里面没有指针的概念,对象指向严格来说应该是引用。但是感觉指针这个词,能更加形象的描述其作用。因此,以下对象的引用统一用指针来代替

链式存储结构的实现,需要一个结点对象,因此首先可以看一下结点类的定义。

包含前驱&后继指针的Node


    private static class Node<E> {
        E item;
        Node<E> next;
        Node<E> prev;

        Node(Node<E> prev, E element, Node<E> next) {
            this.item = element;
            this.next = next;
            this.prev = prev;
        }
    }

可以发现,通过构造函数,就可以创建一个结点,第一个参数为指向前驱的结点,第二个参数为当前结点的值,最后一个参数为指向后继的结点。

下面看看LinkedList是如何初始化的

public class LinkedList<E>
    extends AbstractSequentialList<E>
    implements List<E>, Deque<E>, Cloneable, java.io.Serializable
{
    transient int size = 0;
    transient Node<E> first;
    transient Node<E> last;

    /**
     * Constructs an empty list.
     */
    public LinkedList() {
    }
    public LinkedList(Collection<? extends E> c) {
        this();
        addAll(c);
    }
}

可以看到,LinkedList初始化空链表的操作很简单。

增、删、改、查 的实现

增 & 删

双向链表,由于其结构的特殊性,因此不同于数组,只能在最后添加元素的特性,链表可以在其头部,位置,中间任何位置添加元素,只要能把双向链表串起来,怎么都行。

在非空结点succ之前插入元素e

    /**
     * Inserts element e before non-null Node succ.
     */
    void linkBefore(E e, Node<E> succ) {
        // assert succ != null;
        final Node<E> pred = succ.prev;
        final Node<E> newNode = new Node<>(pred, e, succ);
        succ.prev = newNode;
        if (pred == null)
            first = newNode;
        else
            pred.next = newNode;
        size++;
        modCount++;
    }

对于链表的操作,永远都是那个套路,不要搞错先后顺序,其实很简单。

双向链表插入.png

结合这幅图,链表插入应该很容易理解了。

对于链表中结点的删除,也是同样的道理。

/**
     * Unlinks non-null node x.
     */
    E unlink(Node<E> x) {
        // assert x != null;
        final E element = x.item;
        final Node<E> next = x.next;
        final Node<E> prev = x.prev;

        if (prev == null) {
            first = next;
        } else {
            prev.next = next;
            x.prev = null;
        }

        if (next == null) {
            last = prev;
        } else {
            next.prev = prev;
            x.next = null;
        }

        x.item = null;
        size--;
        modCount++;
        return element;
    }

这里双向链表中的结点移除后,还需要判断一下,是否需要初始化为一个空的双向链表。

改 & 查

    public E get(int index) {
        checkElementIndex(index);
        return node(index).item;
    }

    public E set(int index, E element) {
        checkElementIndex(index);
        Node<E> x = node(index);
        E oldVal = x.item;
        x.item = element;
        return oldVal;
    }

链表的修改和查找操作,首先进行的是位置index的越界检测,然后通过node 方法获取index 位置元素进行相应的操作即可。下面看一下node实现。

    Node<E> node(int index) {
        // assert isElementIndex(index);

        if (index < (size >> 1)) {
            Node<E> x = first;
            for (int i = 0; i < index; i++)
                x = x.next;
            return x;
        } else {
            Node<E> x = last;
            for (int i = size - 1; i > index; i--)
                x = x.prev;
            return x;
        }
    }

可以看到,这里首先会判断一下index位置和整个线性表长度的关系,然后决定是从头部开始后继查找,还是从尾部进行前驱查找。很明显,链式存储结构中,对于查找特定位置的元素,是非常麻烦的。无论怎样,按位置查找,在最坏的情况下,要把一段的元素循环一遍。这在数量级很大的时候,是不划算的。

除了按位置查找之外,还可以按照元素的值进行查找:


public int indexOf(Object o) {
        int index = 0;
        if (o == null) {
            for (Node<E> x = first; x != null; x = x.next) {
                if (x.item == null)
                    return index;
                index++;
            }
        } else {
            for (Node<E> x = first; x != null; x = x.next) {
                if (o.equals(x.item))
                    return index;
                index++;
            }
        }
        return -1;
    }

这个时候,最后情况下,要把所有元素都遍历一遍。

批量操作

批量添加

public boolean addAll(int index, Collection<? extends E> c) {
        checkPositionIndex(index);

        Object[] a = c.toArray();
        int numNew = a.length;
        if (numNew == 0)
            return false;

        Node<E> pred, succ;
        if (index == size) {
            succ = null;
            pred = last;
        } else {
            succ = node(index);
            pred = succ.prev;
        }

        for (Object o : a) {
            @SuppressWarnings("unchecked") E e = (E) o;
            Node<E> newNode = new Node<>(pred, e, null);
            if (pred == null)
                first = newNode;
            else
                pred.next = newNode;
            pred = newNode;
        }

        if (succ == null) {
            last = pred;
        } else {
            pred.next = succ;
            succ.prev = pred;
        }

        size += numNew;
        modCount++;
        return true;
    }

从源码可以看到,使用链表结构批量添加元素是很费劲的,首先集合元素转成数组,然后根据添加位置确定结点,最后还要通过循环逐一将数组中元素串进去。

对于链表的清空操作,clear()方法,同理也需要循环各个元素,释放结点的引用,保证链表清空后,所有对象都能被回收。

其他

如果查看LinkedList的源码,我们还可以发现有peek,push,pop等一系列的方法,这是因为LinkedList同时也是一种特殊的线性表-队列。这点从其定义也可以看出来 ,他实现了Deque接口,因此是一个双端队列。这些方法都是按照队列FILO的思想,对双向链表,实现入队出队的操作;内部的真正实现还是依赖于上面提到几个方法,就不赘述了,有兴趣的同学可以对比一下。

以上,从数据结构的角度,叙述了一下ArrayList和LinkedList。注意不是从集合Collections框架的角度出发,所以暂时略过迭代器Iterator的分析。

ArrayList VS LinkedList

关于二者各自的优缺点,适合在哪些场景使用,通过通篇的描述,相信大家心里都有数了。这里就不再总结了。下面再看一下两个常用的点。

判断空

这两种不同的存储结构,是如何实现空判断的呢?

ArrayList

    public boolean isEmpty() {
        return size == 0;
    }

LinkedList 虽然没有提供明确方法,但我们也可以根据其size做判断的。

toArray

通过Arrays 内部的静态方法asList 可以很方便的把数组转换为列表。而List接口也定义的统一的方法toArray实现列表到数组的转换;需要由每一种特殊的List提供各自的实现。我们可以看一下ArrayList和LinkedList是如何实现toArray方法的。

ArrayList-toArray()

    public Object[] toArray() {
        return Arrays.copyOf(elementData, size);
    }

LinkedList-toArray()

    public Object[] toArray() {
        Object[] result = new Object[size];
        int i = 0;
        for (Node<E> x = first; x != null; x = x.next)
            result[i++] = x.item;
        return result;
    }

很清楚的实现,对于ArrayList,本身即是一个数组,因此只需要把内容复制一份即可(可以看到,列表其实也是数组)。而对于LinkedList就稍微有点麻烦了,他需要创建一个对象数组,把列表里的每一个结点的值取出来。

ArrayList VS Vector

说完了ArrayList和LinkedList,这里再简单的说一下Vector。

  • Vector和ArrayList一样,也是一个动态数组。唯一不同的是Vector是线程安全的,这点从他的源码可以看到,所有的数组操作方法都加了关键字synchronized。因此,当多线程同时操作一个List时,可以考虑使用Vector代替ArrayList。
  • 前面说了,ArrayList每次扩容,会增加原来50%的容量,Vector则是直接增加一倍。
    private void grow(int minCapacity) {
        // overflow-conscious code
        int oldCapacity = elementData.length;
        int newCapacity = oldCapacity + ((capacityIncrement > 0) ?
                                         capacityIncrement : oldCapacity);
        if (newCapacity - minCapacity < 0)
            newCapacity = minCapacity;
        if (newCapacity - MAX_ARRAY_SIZE > 0)
            newCapacity = hugeCapacity(minCapacity);
        elementData = Arrays.copyOf(elementData, newCapacity);
    }

好了,通过以上对ArrayList和LinkedList的分析,对比;相信大家对线性表的线性存储和链式存储有了更深入的认识。

不成熟的小实验

最后,通过一个不成熟的小实验,ArrayList,Vector,LinkedList PK 一下!!

public class DataStructTest {
    private static final int DATA_SIZE = 10 * 10 * 10 * 10 * 10 * 10 * 10;


    public static void main(String[] args) {
        ArrayListTest();
        VectorTest();
        LinkedListTest();
    }

    private static void ArrayListTest() {
        long start = System.nanoTime();

        List<String> datas = new ArrayList<>();
        for (int i = 0; i < DATA_SIZE; i++) {
            datas.add("item-" + i);
        }

        long end = System.nanoTime();
        System.err.printf("ArrayList  Add %d element in %d nanoseconds\n", DATA_SIZE, (end - start));

        start = System.nanoTime();
        String data = datas.get(DATA_SIZE / 2);
        end = System.nanoTime();


        System.err.printf("Get data %s from ArrayList  at pos=%d in %d nanoseconds\n", data, DATA_SIZE / 2, end - start);
    }

    private static void VectorTest() {
        long start = System.nanoTime();

        List<String> datas = new Vector<>();
        for (int i = 0; i < DATA_SIZE; i++) {
            datas.add("item-" + i);
        }

        long end = System.nanoTime();
        System.err.printf("Vector     Add %d element in %d nanoseconds\n", DATA_SIZE, end - start);

        start = System.nanoTime();
        String data = datas.get(DATA_SIZE / 2);
        end = System.nanoTime();


        System.err.printf("Get data %s from Vector     at pos=%d in %d nanoseconds\n", data, DATA_SIZE / 2, end - start);
    }

    private static void LinkedListTest() {
        long start = System.nanoTime();

        List<String> datas = new LinkedList<>();
        for (int i = 0; i < DATA_SIZE; i++) {
            datas.add("item-" + i);
        }

        long end = System.nanoTime();
        System.err.printf("LinkedList Add %d element in %d nanoseconds\n", DATA_SIZE, end - start);

        start = System.nanoTime();
        String data = datas.get(DATA_SIZE / 2);
        end = System.nanoTime();


        System.err.printf("Get data %s from LinkedList at pos=%d in %d nanoseconds\n", data, DATA_SIZE / 2, end - start);

    }
}

上面的代码,使用三种结构实现添加DATA_SIZE个数据到List中,最后从第DATA_SIZE/2个位置返回元素。当然这样对LinkedList来说是不太公平的。总之,这是一个不成熟的小实验。看一下返回结果吧!

ArrayList  Add 10000000 element in 7491830924 nanoseconds
Get data item-5000000 from ArrayList  at pos=5000000 in 7106 nanoseconds
Vector     Add 10000000 element in 3810582375 nanoseconds
Get data item-5000000 from Vector     at pos=5000000 in 3948 nanoseconds
LinkedList Add 10000000 element in 3475231051 nanoseconds
Get data item-5000000 from LinkedList at pos=5000000 in 73783797 nanoseconds

这是运行结果,当然每次运行的结果都是不一样的,但是总的趋平均计算的话每次都是相同的。

ArrayList 和 Vector 这样的线性存储结构中,查找某个特定位置的元素是,速度比LinkedList快了差不多一万倍,而添加操作时,LinkedList总的来说,会快一些。

好了,从数据结构的角度说ArrayList和LinkedList就到这里了。


如果本文中有不正确的结论、说法,请大家提出和我讨论,共同进步,谢谢。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,233评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,013评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,030评论 0 241
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,827评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,221评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,542评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,814评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,513评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,225评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,497评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,998评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,342评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,986评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,812评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,560评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,461评论 2 266

推荐阅读更多精彩内容