Categories
不学无术

用于Sentence Embedding的DSSM与LSTM:管中窥豹

前置废话:感觉实习这两个月真是顶得上实验室半年,想想对不起我的导师,跟现在相比之前天天像是在打酱油啊。

相关文献

  1. Huang, Po-Sen, et al. “Learning deep structured semantic models for web search using clickthrough data.” Proceedings of the 22nd ACM international conference on Conference on information & knowledge management. ACM, 2013.
  2. Palangi, Hamid, et al. “Deep sentence embedding using long short-term memory networks: Analysis and application to information retrieval.” IEEE/ACM Transactions on Audio, Speech, and Language Processing 24.4 (2016): 694-707.

DSSM一撇

DSSM了解的不是很多,但是这两篇文章有几处是类似的,隐隐地感觉后面这篇是借鉴前面的写出来的。
文章中DSSM的最终目标是实现一个神经网络模型,使得给定输入文档(片段)可以输出一个对应的向量,而通过向量之间的余弦相似度可以判断两个Query-Document的相似性[latex]R(Q,D)=cosine(y_Q,y_D)[/latex],然后拿这个就能做搜索排序了。

Word-Hashing

DSSM中用到了一个奇淫巧术,就是所谓的Word Hashing方法。我们知道,要将文字拿来处理,首先要表示成向量的形式(数值化),最普通的方法是做One-Hot Vector, 即每个单词对应一个feature值,出现就是1,没出现就是0. 但是单词表实在是太大了,这篇论文用到了一个叫Letter-N-Gram的技术,即将一个单词强行分成几个n-gram. 例如单词Hello做Letter-3-Gram,就会变成#HE, HEL, ELL, LLO, LO# 这样的表示方法(其中#代表开头或结尾)。由于处理的Query/Doc对中特殊字符会在数据预处理阶段被过滤,因此抛开单词大小写,a-z0-9#一共就26+10+1=37个字符,37*37*37=50653种可能性,相比词典来说小多了!
这个办法初看不是很靠谱,但是实验结果却是不错的。

正例/反例

训练神经网络需要给正例以及反例。不出意外地,这里用的是用户的click stream,即用户的点击数据。假设[latex]D[/latex]表示对于一个查询Q的所有候选文档,那么[latex]D^+[/latex]表示用户点击的文档作为正例,[latex]D^-[/latex]表示没有点击的作为负例。再根据上面提到的相似度[latex]R(Q,D)[/latex],可以做出一个softmax的后验概率:

[latex]P(D|Q)=\frac{exp(\gamma R(Q,D))}{\sum_{D’ \in D}exp(\gamma R(Q,D’))}[/latex]

通俗易懂,对吧。

神经网络结构

DSSM
想要实战的,可以看看CNTK里面有一个类似的例子:网络定义文件在这里,不过这个文件里面已经是Word Hashing过之后的了,而且参数设置和论文里的不大一样。
上面Figure1可以根据每一列来看。输入的最底层是Query/Doc的高纬度的词向量(Term Vector),然后进入了Word Hashing阶段把高维词向量弄成了L3G之类的向量,维度大概在30k~50k之间,然后加了两个隐藏层后输出了最终的128维向量y。这个向量y在训练结束之后就是传说中的词向量了。然后两个词向量之间可以算出相似度,最终给出一个softmax算的后验概率[latex]P(D_i|Q)[/latex]。
训练整一坨神经网络要给一个优化目标,文中定义了一个loss function:

[latex]L(\Lambda)=-log\prod_{(Q,D^+)}P(D^+|Q)[/latex]

其中[latex]\Lambda[/latex]表示训练神经网络时的一堆参数。然后使用梯度下降之类的方法,把梯度一层层往回传就能调整参数了。

RNN与LSTM

RNN/LSTM具体是怎么工作的?要详细理解可以看这篇文章,讲得很透彻。这里只提到论文中的一些实现。

RNN

 
RNN Sentence Embedding
RNN的结构特点是下一个平行节点状态的信息是基于之前节点的。如上图,在本文的环境中,底下的x0,x1,x2,…可以看做一个个单词的输入,这一长串的节点连在一起就变成了一个句子。上面做DSSM时,一整句(段)话的词都拿来一起做Word Hashing,结果就是词汇之间的信息从一开始就会被忽略掉,如果换成了RNN,虽然最后拿到的还是个句子的向量,但是理论上训练出的模型会更加准确一些。中间层是Word Hashing之后的层,用的应该是和DSSM一样的Hashing方法。在最右侧(最后一个词)的激活输出就是Embedding好的句子,一个句子向量:)
但是RNN的引入会引出“梯度爆炸”或“梯度消失”的问题。数学原理不多说,考虑这个句子:I grew up in France… I speak fluent French. 假设我的任务是要训练一个RNN,根据之前的所有单词信息“猜”最后一个词汇French。在这句话中,显然最适合用来参考的是句子开头的那个词France,但是中间还隔了一大堆的“没卵用”词。在根据标签判断loss函数做梯度下降的时候,梯度一层层往回传,但由于中间隔的比较远,等轮到France这个词的时候已经几乎没有影响了。

LSTM-DSSM

Sentence Embedding: LSTM - DSSM
LSTM处于什么位置呢?我把两个文章的对比图放在这边,基本上就能看出来了。左图里面用橙色圈出的结构,他的作用和右侧黑色虚线框里的东西是一样的,只是LSTM结构取代了原本比较“单纯”的多层网络的地位。所以这个论文的方法似乎可以叫做LSTM-DSSM.
所以对于怎么做学习,这个新文章的方法也是与它的前身很类似的:根据用户点击的Query-Doc来确定正例及反例(右图中的[latex]D^+[/latex]与[latex]D^-[/latex]),求一个最大似然,它等同于最小化下面这个公式中的参数[latex]\Lambda[/latex]。

[latex]L(\Lambda)=-log\prod_{(Q,D^+)}P(D^+|Q)=\sum_{r=1}^{n}l_r(\Lambda)[/latex]

ML of DSSM-LSTM
其中,r对应第r条Query, j是这条Query对应的一条Document。
多说一句,这个Query-Doc的匹配过程有点像做Translation Model,只不过Translation Model有Encoding和Decoding两步,而这里只有Encoding的过程。

LSTM结构

简单来说,LSTM是一种RNN结构,里面通过一些Gate来实现“要不要记住/考虑XXX”的选择。LSTM根据需要有不同的具体实现,下图是论文中用到的结构:
LSTM Cell
这个结构分成了几个关键部位:Input Gate, Output Gate 和 Forget Gate. 可以看到这些Gate的实现是Sigmoid输出的(图中[latex]\sigma(.)[/latex]),所以值都在0-1之间;然后和前面的h(.)去做一个pointwise multiple(X),实现了“门”的功能。

  • Input Gate决定了在这个LSTM结构中哪些值是要更新的,即是否要把当前的输入信息加入到隐藏层的状态;
  • Output Gate决定了是否把当前这个LSTM的输出传到下一层去;
  • Forget Gate决定了是否要保留当前一个节点的历史状态c(t-1),可以看到图中有一个圈状的结构

 

Categories
不学无术

LeetCode 146. LRU Cache

思路

双向链表+哈希表实现,要明白STL里的<map>是红黑树实现的,亲测速度不够,要用真.哈希表:<unordered_map>
可能有人要问,为啥不用单向链表?我的理解是为了删除元素的时候不用从头搜索。
当然,针对这道题是可以用单向链表的,即“删除”最无用节点的时候,对链表不作处理,而只是从哈希表里把值删掉。反正在搜索的时候也是直接查的哈希表。副作用是这个链表会越来越长。
另外,不想自己实现双向链表的话,可以用<list>

代码

struct LinkedNode{
    int key;
    LinkedNode* next;
    LinkedNode* prev;
    LinkedNode(int _key = -1){
        key = _key;
        next = NULL;
        prev = NULL;
    }
};
class LRUCache{
private:
    LinkedNode* pHeader;
    LinkedNode* pEnd;
    unordered_map<int, int> keyValueMap;
    int cap;
public:
    LRUCache(int capacity) {
        cap = capacity;
        pHeader = new LinkedNode(); //dummy node
        pEnd = new LinkedNode();
        pHeader->next = pEnd;
        pEnd->prev = pHeader;
    }
    void refreshQueue(int key){
        LinkedNode* pNode = pHeader;
        while(pNode->next != NULL){
            if(pNode->next->key == key)
                break;
            pNode = pNode->next;
        }
        //Move pNode->next to head
        LinkedNode* pPrevNode = pNode;
        LinkedNode* pHitNode = pNode->next;
        LinkedNode* pNextNode = pHitNode->next;
        pPrevNode->next = pNextNode;
        pNextNode->prev = pPrevNode;
        pHeader->next->prev = pHitNode;
        pHitNode->prev = pHeader;
        pHitNode->next = pHeader->next;
        pHeader->next = pHitNode;
    }
    int removeLast(){
        //Remove the last item and return its key
        LinkedNode* pToRemove = pEnd->prev;
        int key = pToRemove->key;
        LinkedNode* pPrev = pToRemove->prev;
        pPrev->next = pEnd;
        pEnd->prev = pPrev;
        delete pToRemove;
        return key;
    }
    int get(int key) {
        if(keyValueMap.count(key) > 0){
            //Change priority queue
            refreshQueue(key);
            //Get value
            return keyValueMap[key];
        } else {
            return -1;
        }
    }
    void insert(int key){
        LinkedNode* pNext = pHeader->next;
        LinkedNode* pNewNode = new LinkedNode(key);
        pNewNode->next = pNext;
        pNewNode->prev = pHeader;
        pHeader->next = pNewNode;
        pNext->prev = pNewNode;
    }
    void set(int key, int value) {
        if(keyValueMap.count(key)){
            keyValueMap[key] = value; //set key
            //Refresh queue
            refreshQueue(key);
        } else {
            if(keyValueMap.size() == cap){
                int oldKey = removeLast();
                keyValueMap.erase(oldKey);
            }
            keyValueMap[key] = value;
            insert(key);
        }
    }
};

 

Categories
不学无术

LeetCode 139. Word Break

Given a string s and a dictionary of words dict, determine if s can be segmented into a space-separated sequence of one or more dictionary words.
For example, given
s = "leetcode",
dict = ["leet", "code"].
Return true because "leetcode" can be segmented as "leet code".

思路

字符串处理的一个题目,首先想到的办法就是穷举:从头开始找N-letter word,查一下在不在字典里呗。但是纯穷举的话显然就会超时,因为这个问题可以分解成下面的子问题,即给定字符串s及起始位置start,寻找s[start…end]能不能够被拆分。这个子问题在暴力搜索的时候很容易被重复,所以得找个东西记录一下状态,碰到相同的子问题就不用再去查字典了。
所以,他是个动态规划的题。

代码

bool wordBreakSub(const string& s, unordered_set<string>& wordDict, int startPos, int* memory)
{
    if(memory[startPos] != -1)
        return memory[startPos]!= 0;
    string subStr;
    bool canBreak = false;
    for(int len=1; len <= s.size() - startPos; len++)
    {
        subStr = s.substr(startPos, len);
        if(wordDict.count(subStr) > 0) {
            if (startPos + len == s.size()) {
                memory[startPos] = 1;
                return true;
            }
            canBreak = wordBreakSub(s, wordDict, startPos + len, memory);
        }
        if(canBreak)
            break;
    }
    memory[startPos] = canBreak ? 1 : 0;
    return canBreak;
}
bool wordBreak(string s, unordered_set<string>& wordDict)
{
    int* memory = new int[s.size()];
    for(int i=0; i<s.size(); i++)
        memory[i] = -1; //-1:untest; 0-false; 1-true
    return wordBreakSub(s, wordDict, 0, memory);
}

 

Categories
不学无术

LeetCode 116. Populating Next Right Pointers in Each Node

思路

发现是一个树的遍历的题目,而且题目限制的比较死,是个满二叉树,这样处理的时候就避免了比较烦人的跨树指针的情况。比如下面这种建立4->5的指针:

    1
  2   3
4    5  6

第一时间想到的解法是弄个工作队列,然后一层一层遍历下去就行了,这个解法的空间复杂度是O(n),不符合题目要求。后来再一想,其实既然上一层已经把下一层的next指针建立好了,就不用再从工作队列里去“回忆”了,只要在每一行的开头记住下一行在哪里就行,这样只要O(1)空间复杂度。
另外是如何设置next指针,一共有两个情况:

  1. node自己的左孩子和右孩子连起来
  2. node的右孩子与node右侧节点它的左孩子连起来

代码

O(n)空间复杂度方法

/**
 * Definition for binary tree with next pointer.
 * struct TreeLinkNode {
 *  int val;
 *  TreeLinkNode *left, *right, *next;
 *  TreeLinkNode(int x) : val(x), left(NULL), right(NULL), next(NULL) {}
 * };
 */
class Solution {
public:
    void connect(TreeLinkNode *root) {
        queue<TreeLinkNode*> workingQ;
        if(root == NULL)
            return;
        workingQ.push(root);
        while(!workingQ.empty())
        {
            TreeLinkNode* node = workingQ.front();
            workingQ.pop();
            if(node->left == NULL || node->right == NULL)
                continue;   //Leaf node
            node->left->next = node->right; //Inner connection
            if(node->next != NULL)
                node->right->next = node->next->left; //Outter connection
            workingQ.push(node->left);
            workingQ.push(node->right);
        }
    }
};

O(1)空间复杂度方法

class Solution {
public:
    void connect(TreeLinkNode *root) {
        TreeLinkNode* nextRowPtr = root;
        TreeLinkNode* node = root;
        bool atRowStart = true;
        while(nextRowPtr != NULL)
        {
            if(node->left == NULL || node->right == NULL)
                break;   //Leaf node
            if(atRowStart)
            {
                atRowStart = false;
                nextRowPtr = node->left;
            }
            node->left->next = node->right; //Inner connection
            if(node->next != NULL)
            {
                node->right->next = node->next->left; //Outter connection
                node = node->next;
            }
            else
            {
                node = nextRowPtr;
                atRowStart = true;  //End of current row
            }
        }
    }
};

 

Categories
不学无术 木有技术

LeetCode 385. Mini Parser

思路

一开始还以为又回到了编译原理,想到了状态转换图啊之类的。后来发现这个题目没有这么变态,要考虑的是个栈的问题。
为什么会想到栈呢?因为看到了括号,因为括号结束的时候我们要把某个部分的内容添到更大的括号里头去。
我的处理方式是:

  • 构造一个工作栈,一开始是空的
  •  碰到数字
    • 生成一个只包含数字的NestedInteger
      • 如果工作栈空,那么字符串就是个纯数字,直接返回
      • 如果工作栈非空,那么这个字符串就要加到当前工作栈顶端的元素后面(考虑情况[35,345]这种)
  • 碰到'[‘
    • 新建一个空的NestedInteger(至于里面填什么,不是这里要考虑的事儿)
    • 把它压入栈
  • 碰到’]’
    • 取出当前栈顶元素formerTop
    • 判断栈是否空了
      • 非空,则formerTop要加到当前栈顶的后面
      • 空,可以返回了
  • 碰到’,’
    • 其实没什么用,往前挪动吧

这个步骤还能再精简一下的,判断栈是否空,是否返回的部分是重复了

代码

/**
 * // This is the interface that allows for creating nested lists.
 * // You should not implement it, or speculate about its implementation
 * class NestedInteger {
 *   public:
 *     // Constructor initializes an empty nested list.
 *     NestedInteger();
 *
 *     // Constructor initializes a single integer.
 *     NestedInteger(int value);
 *
 *     // Return true if this NestedInteger holds a single integer, rather than a nested list.
 *     bool isInteger() const;
 *
 *     // Return the single integer that this NestedInteger holds, if it holds a single integer
 *     // The result is undefined if this NestedInteger holds a nested list
 *     int getInteger() const;
 *
 *     // Set this NestedInteger to hold a single integer.
 *     void setInteger(int value);
 *
 *     // Set this NestedInteger to hold a nested list and adds a nested integer to it.
 *     void add(const NestedInteger &ni);
 *
 *     // Return the nested list that this NestedInteger holds, if it holds a nested list
 *     // The result is undefined if this NestedInteger holds a single integer
 *     const vector<NestedInteger> &getList() const;
 * };
 */
class Solution {
private:
    stack<NestedInteger*> workingStack;
public:
    int readIntVal(const string& s, int& pos)
    {
        // pos will be set to the end index of current integer
        int value = 0;
        bool neg = false;
        if(s[pos] == '-'){
            neg = true;
            pos++;
        }
        while(pos < s.size() &&
                (s[pos] >= '0' && s[pos] <= '9'))
        {
            value *= 10;
            value += (s[pos] - '0');
            pos++;
        }
        pos--;
        return neg? -value : value;
    }
    NestedInteger deserialize(string s) {
        int pos = 0;
        while(pos < s.size())
        {
            char c = s[pos];
            if(c == '-' || (c >= '0' && c <= '9'))
            {
                NestedInteger* digitNI = new NestedInteger(readIntVal(s, pos)); //Integer containing NestedInteger
                if(workingStack.empty())
                {
                    //Should return
                    return *digitNI;
                }
                else
                {
                    //Append to existing NI
                    NestedInteger* currNI = workingStack.top();
                    currNI->add(*digitNI);
                    pos++;
                }
            }
            else if(c == ',')
            {
                pos++;
            }
            else if(c == '[')
            {
                //Create an NI and push to working stack
                NestedInteger* ni = new NestedInteger();
                workingStack.push(ni);
                pos++;
            }
            else if(c == ']')
            {
                //pop workingStack and append former-top item to current top
                NestedInteger* formerTop = workingStack.top();
                workingStack.pop();
                if(workingStack.empty())
                    return *formerTop;
                else
                {
                    //Append
                    NestedInteger* currTop = workingStack.top();
                    currTop->add(*formerTop);
                    pos++;
                }
            }
        }
        //Shouldn't be here!
        return NestedInteger();
    }
};

题目

Given a nested list of integers represented as a string, implement a parser to deserialize it.
Each element is either an integer, or a list — whose elements may also be integers or other lists.
Note: You may assume that the string is well-formed:

  • String is non-empty.
  • String does not contain white spaces.
  • String contains only digits 0-9, [, - ,, ].

Example 1:

Given s = "324",
You should return a NestedInteger object which contains a single integer 324.

Example 2:

Given s = "[123,[456,[789]]]",
Return a NestedInteger object containing a nested list with 2 elements:
1. An integer containing value 123.
2. A nested list containing two elements:
    i.  An integer containing value 456.
    ii. A nested list with one element:
         a. An integer containing value 789.
Categories
木有技术

LeetCode 14. Longest Common Prefix

Write a function to find the longest common prefix string amongst an array of strings.

分析

这个题目直接扫一遍就行了,复杂度是O(min(strlen)),strlen是字符串的长度

代码

class Solution {
public:
    string longestCommonPrefix(vector<string>& strs) {
        if(strs.size() == 0)
            return "";
        else if(strs.size() == 1)
            return strs[0];
        int currPos = 0;
        bool shouldStop = false;
        while(!shouldStop){
            char c;
            for(int row=0; row < strs.size(); row++){
                if(strs[row].size() == currPos){
                    shouldStop = true;//Meet the end of some string
                    break;
                }
                if(row == 0){
                    c = strs[row][currPos];
                } else {
                    if(strs[row][currPos] != c){
                        shouldStop = true;
                        break;
                    }
                }
            }
            if(!shouldStop)
                currPos++;
        }
        return strs[0].substr(0, currPos);
    }
};

 

Categories
不学无术

LeetCode 343. Integer Break

Given a positive integer n, break it into the sum of at least two positive integers and maximize the product of those integers. Return the maximum product you can get.
For example, given n = 2, return 1 (2 = 1 + 1); given n = 10, return 36 (10 = 3 + 3 + 4).
Note: You may assume that n is not less than 2 and not larger than 58.

思路 O(n^2)

注:这题还有O(n)解决的方法,我提交的方法是O(n^2)的。
有点像动态规划的题,上一个数的状态经过乘法可以作为下个数的备选(要与下个数比较)。
假设result[i]存了当前能加到i的最好结果值;
第一遍,从1开始往后加(反正最大的n也就58),1+1=2,则result[2]中就是1*1=1,1+2=3=>result[3]=1*2=2;result[4]=3
第二遍,从2开始往后加,a[3]=2,a[4]=2*2(原始值为3,这里更新为4),…

第五遍,从5开始往后加,但之前算的过程中,result[5]的结果已经被更新成了6,这个数比5大,因此×的时候,被乘数是6而不是5;
以此类推…

思路 O(logn)

需要一点数学思维,详见https://discuss.leetcode.com/topic/42991/o-log-n-time-solution-with-explanation

代码

public class Solution {
    public int max(int x, int y){
        return x>y ? x : y;
    }
    public int integerBreak(int n) {
        int[] result = new int[59]; //0 to 58
        result[1] = 1;  //dummy
        for(int i=1; i<=n-1; i++)
        {
            for(int j=1; j<=n-i; j++)
            {
                int temp = result[i];
                if(result[i] < i)
                    temp = i;
                result[i+j]=max(result[i+j], temp * j);
            }
        }
        return result[n];
    }
}

 

Categories
木有技术

LeetCode 62. Unique Paths

原题:https://leetcode.com/problems/unique-paths/
robot is located at the top-left corner of a m x n grid (marked ‘Start’ in the diagram below).
The robot can only move either down or right at any point in time. The robot is trying to reach the bottom-right corner of the grid (marked ‘Finish’ in the diagram below).
How many possible unique paths are there?

Above is a 3 x 7 grid. How many possible unique paths are there?
Note: m and n will be at most 100.

思路

这是个经典的题目,反正就是动态规划的思路?说是动态规划,其实就是下一个状态怎么来的问题。此处的下一状态就是一个格子solution[m][n]他的结果是怎么来的问题,由于限定只能往右或者往下走,所以这个格子的走法无非是 从(m,n-1)和(m-1,n)走过来两种,只要把这两个状态的结果相加就是当前总的走法了。

解法

复杂度应该是O(m*n),做的时候在左侧和上侧加了个0边便于计算

public class Solution {
    public int uniquePaths(int m, int n) {
        int solutions[][] = new int[m+1][n+1];
        solutions[1][1]=1;
        for(int i=1; i<=m; i++){
            for(int j=1; j<=n; j++){
                if(i==1 && j==1)
                    continue;
                solutions[i][j] = solutions[i][j-1] + solutions[i-1][j];    // Comes from left, or upper side
            }
        }
        return solutions[m][n];
    }
}

 

Categories
木有技术

LeetCode 278. First Bad Version

做点简单的题目练练手,最近天天在写Scope脚本,都快忘记怎么写其他代码了。
吐槽一下垃圾长城宽带,上LeetCode都要自己开代理,不然根本加载不了

You are a product manager and currently leading a team to develop a new product. Unfortunately, the latest version of your product fails the quality check. Since each version is developed based on the previous version, all the versions after a bad version are also bad.
Suppose you have n versions [1, 2, ..., n] and you want to find out the first bad one, which causes all the following ones to be bad.
You are given an API bool isBadVersion(version) which will return whether version is bad. Implement a function to find the first bad version. You should minimize the number of calls to the API.

分析

这其实就是个变种的二分查找,只要注意找到一个bad version的时候,往前多看一个版本就能决定是否要继续二分下去了。

代码

// Forward declaration of isBadVersion API.
bool isBadVersion(int version);
class Solution {
public:
    int firstBadVersion(int n) {
        int low = 1;
        int high = n;
        while(true){
            int mid = low + (high - low)/2;
            if(isBadVersion(mid)){
                //Look for previous cases
                bool isBad = isBadVersion(mid);
                if(isBad){
                    if(mid == 1)
                        return mid;
                    if(!isBadVersion(mid-1))
                        return mid;
                    high = mid - 1;
                } else {
                    low = mid + 1;
                }
            } else {
                low = mid + 1;
            }
        }
    }
};

 

Categories
iDairy

长城宽带迅雷下BT缓慢临时解决办法

在北京实习,当上了伪北漂。因为只租三个月左右,权衡各种就选了长城宽带,这个价格算下来其实跟去年双自己家11买的电信100M差不多,不过质量么就呵呵哒了。360元(号称)70M,3个月。长宽国内访问还好,一到国际出口就坑比了,Github上面Push东西都Push不上去,国内的估计是做了缓存的,常见资源访问很快,不是热点的资源就得加载卡一点,下载差不多3MB/s吧,就是电信20M的水平。
一句话:搭个国内代理(比如SS)先下载几分钟,然后切回到长城宽带继续下载。
吐槽结束,然后最近找电影看,发现BT很容易就下不动。之前有在哪里看到过说长城宽带封迅雷的,抱着试试看的心理试了试别的办法。由于自己有个阿里云ECS的账号,所以在上面搭了个sh@d0ws0cks服务器。众所周知这货是翻q用的,但是可以开全局代理啊。然后我试了一下一模一样的一个电影种子,原来无法访问离线下载的毛病解决了,速度瞬间上去。稳定一阵后把SS切回原样照样能下载。
我深深怀疑是封端口之类的了,一开始类似建立连接的步骤没办法进行就一直卡着,后面大概p2p端口多了也封不完?纯属胡猜,反正管用