【问题标题】:Dictionary both case sensitive and insensitive字典区分大小写和不区分大小写
【发布时间】:2020-02-18 01:46:12
【问题描述】:

我需要一个像 Dictionary<string,T> 这样的数据结构,我可以在其中进行区分大小写和不区分大小写的搜索。

我希望通过使用区分大小写或不区分大小写的 StringComparer 迭代 foreach 来改进使用 List<Tuple<string,T>> 可以获得的 O(n) 时间。

这适用于我希望最终用户在 Search 方法调用上选择区分大小写的库。 (否则我可以在类构造函数中创建一个不同的字典,灵敏度开/关)

有什么想法吗?

【问题讨论】:

  • var comparer = StringComparer.OrdinalIgnoreCase; var caseInsensitiveDictionary = new Dictionary<string, int>(comparer); 基本上定义了要使用的比较器
  • Right, but how do I quantify how many entries should this double structure have in order to the benefit in performance be bigger than doubling memory cost and the increased (2x) Add() cost. ericlippert.com/2012/12/17/performance-rant
  • 我建议创建一个不区分大小写的字典,最初对其进行所有搜索,当需要区分大小写搜索时,通过区分大小写比较过滤结果。
  • 在不区分大小写的情况下,您能多说一下这本词典中有多少条目会发生冲突吗?也就是说,您是否希望字典中有Japanjapan,例如,两个或三个碰撞,或者您希望有bananaramabananaRamaBanaNaRaMa,...有几十个或数百或数千次碰撞?您应该选择哪种算法会有所不同。
  • @GerardoGrignoli:你为什么要关心“不当”使用是否会产生不好的结果?如果用户滥用您的工具,那么他们为工作选择了错误的工具。如果您关心这种情况,那么您有一个非常困难的问题需要解决,您不应该使用现成的字典。您应该研究您关心的虐待案例,并寻求建立一个专门用于在面对虐待时表现良好的专用字典。

标签: c# data-structures case-sensitive case-insensitive


【解决方案1】:

您可以只使用普通字典,但定义一个扩展方法来执行不区分大小写的搜索:

static class ExtensionMethods
{
    static public T GetValue<T>(this Dictionary<string,T> source, string key, bool caseSensitive)
    {
        if (caseSensitive) return source[key];
        key = source.Keys.FirstOrDefault( k => String.Compare(key, k, StringComparison.CurrentCultureIgnoreCase) == 0);
        if (key == null) throw new KeyNotFoundException();
        return source[key];
    }
}

或者,如果你真的想要,你可以将字典子类化,并使上面的内容成为适当的实例成员。

【讨论】:

  • 这可行,但 AFAICT,它仍然像 List&lt;Tuple&lt;string,T&gt;&gt; 实现一样 O(n),对吧?
  • (勘误表) 这允许我以常规方式使用字典来区分大小写,O(1)。并且为了不区分大小写而回退到 O(n),所有这些都不会加倍数据结构。这是一个进步!
【解决方案2】:

你肯定不会写自己的字典(派生词)。第一个值是键。因此,它仅适用于 exact 匹配,而不是非区分大小写的匹配。其实更糟糕的是:

我最近了解到 Dictionary 也是我们的通用 Hashtable。它使用 Hashtable 方法(获取每个键和输入的哈希值并首先进行比较),以加快比较速度,尤其是在字符串等内容上。因此,在查找密钥时,它会通过密钥集合并:

  1. 比较哈希值。如果它们不匹配,这不可能是关键。
  2. 如果它们匹配,则对类型进行全面比较。哈希冲突是一回事,因此哈希只能用于早期的“不匹配”过滤。

您的要求有点打破了这一点。完全。实际上,当它应该匹配时,由于散列,你最终会出现不匹配。

第一个解决方案是停止尝试在代码中执行此操作,而是转而使用适当的 DBMS。他们倾向于支持您可能想到的所有奇怪的比较。有很多方法可以加速它们,比如索引。那里应该有一个进程内数据库。但很少有人愿意走这条路。

我能想到的第二种解决方案是尝试重写字典,尽可能少的工作。一些想法:

  • 密钥应仅以大写或小写形式存储,不影响用户输入的内容。我将使用小写字母,因为这对我来说很直观,只需拨打 .toLower() 即可。
  • 您需要存储完整的密钥、外壳和所有内容。为简单起见,我会在值中添加一个 htat 字段,假设您真的确定没有人会修改该字段。
  • 查找键时,首先使用内置匹配输入的小写版本。然后(如果需要)还要检查原始密钥,然后再报告匹配/不匹配。

你基本上在我上面的清单中添加了第 3 步:

  1. 如果输入的小写与(小写)键匹配并且需要区分大小写,现在检查存储的大小写键与大小写输入

希望只修改添加和查找例程。像 remove 之类的东西应该 使用 find 函数首先找到元素。这有点hacky。理想情况下,您希望对用户隐藏您如何执行此操作的内部信息,因此大小写密钥列表应该是私有的。当然,这意味着必须接触更多的代码。

【讨论】:

    【解决方案3】:

    经过进一步思考并阅读 cmets,我认为最好的实现是使用新的不区分大小写的属性和方法扩展看似区分大小写的 Dictionary。由于实现是基于一个不区分大小写的Dictionary 持有区分大小写的子字典,并且C# 没有私有继承,因此最好只实现一个新的Dictionary 包装器。

    public class CaseDictionary<TValue> : IDictionary<string, TValue>, IDictionary, IReadOnlyDictionary<string, TValue> {
        #region Members
        Dictionary<string, Dictionary<string, TValue>> CIDict;
        #endregion
    
        #region Constructors
        public CaseDictionary() {
            CIDict = new Dictionary<string, Dictionary<string, TValue>>(StringComparer.OrdinalIgnoreCase);
        }
    
        public CaseDictionary(int init) {
            CIDict = new Dictionary<string, Dictionary<string, TValue>>(init, StringComparer.OrdinalIgnoreCase);
        }
    
        public CaseDictionary(IDictionary<string, TValue> init)
            : this(init != null ? init.Count : 0) {
            foreach (var kvp in init)
                Add(kvp.Key, kvp.Value);
        }
        #endregion
    
        #region Properties
        public ICollection<string> Keys => CIDict.Values.SelectMany(v => v.Keys).ToList();
        public ICollection<TValue> Values => CIDict.Values.SelectMany(v => v.Values).ToList();
        public int Count => CIDict.Values.Select(v => v.Count).Sum();
    
        public TValue this[string aKey]
        {
            get
            {
                if (CIDict.TryGetValue(aKey, out var possibles) && possibles.TryGetValue(aKey, out var theValue))
                    return theValue;
                throw new KeyNotFoundException();
            }
            set
            {
                if (CIDict.TryGetValue(aKey, out var possibles)) {
                    if (possibles.ContainsKey(aKey))
                        possibles[aKey] = value;
                    else
                        possibles.Add(aKey, value);
                }
                else
                    CIDict.Add(aKey, new Dictionary<string, TValue>() { { aKey, value } });
            }
        }
        #endregion
    
        #region Methods
        public void Add(string aKey, TValue aValue) {
            if (CIDict.TryGetValue(aKey, out var values))
                values.Add(aKey, aValue);
            else
                CIDict.Add(aKey, new Dictionary<string, TValue>() { { aKey, aValue } });
        }
    
        public bool ContainsKey(string aKey) {
            if (CIDict.TryGetValue(aKey, out var possibles))
                return possibles.ContainsKey(aKey);
            else
                return false;
        }
    
        public bool Remove(string aKey) {
            if (CIDict.TryGetValue(aKey, out var possibles))
                return possibles.Remove(aKey);
            else
                return false;
        }
    
        public bool TryGetValue(string aKey, out TValue theValue) {
            if (CIDict.TryGetValue(aKey, out var possibles))
                return possibles.TryGetValue(aKey, out theValue);
            else {
                theValue = default(TValue);
                return false;
            }
        }
        #endregion
    
        #region ICollection<KeyValuePair<,>> Properties and Methods
        bool ICollection<KeyValuePair<string, TValue>>.IsReadOnly => false;
    
        void ICollection<KeyValuePair<string, TValue>>.Add(KeyValuePair<string, TValue> item) => Add(item.Key, item.Value);
        public void Clear() => CIDict.Clear();
    
        bool ICollection<KeyValuePair<string, TValue>>.Contains(KeyValuePair<string, TValue> item) {
            if (CIDict.TryGetValue(item.Key, out var possibles))
                return ((ICollection<KeyValuePair<string, TValue>>)possibles).Contains(item);
            else
                return false;
        }
    
        bool ICollection<KeyValuePair<string, TValue>>.Remove(KeyValuePair<string, TValue> item) {
            if (CIDict.TryGetValue(item.Key, out var possibles))
                return ((ICollection<KeyValuePair<string, TValue>>)possibles).Remove(item);
            else
                return false;
        }
    
        public void CopyTo(KeyValuePair<string, TValue>[] array, int index) {
            if (array == null)
                throw new ArgumentNullException("array");
            if (index < 0 || index > array.Length)
                throw new ArgumentException("index must be non-negative and within array argument Length");
            if (array.Length - index < Count)
                throw new ArgumentException("array argument plus index offset is too small");
    
            foreach (var subd in CIDict.Values)
                foreach (var kvp in subd)
                    array[index++] = kvp;
        }
        #endregion
    
        #region IDictionary Methods
        bool IDictionary.IsFixedSize => false;
        bool IDictionary.IsReadOnly => false;
        ICollection IDictionary.Keys => (ICollection)Keys;
        ICollection IDictionary.Values => (ICollection)Values;
    
        object IDictionary.this[object key]
        {
            get
            {
                if (key == null)
                    throw new ArgumentNullException("key");
                if (key is string aKey)
                    if (CIDict.TryGetValue(aKey, out var possibles))
                        if (possibles.TryGetValue(aKey, out var theValue))
                            return theValue;
    
                return null;
            }
            set
            {
                if (key == null)
                    throw new ArgumentNullException("key");
                if (value == null && default(TValue) != null)
                    throw new ArgumentNullException("value");
                if (key is string aKey) {
                    if (value is TValue aValue)
                        this[aKey] = aValue;
                    else
                        throw new ArgumentException("value argument has wrong type");
                }
                else
                    throw new ArgumentException("key argument has wrong type");
            }
        }
    
        void IDictionary.Add(object key, object value) {
            if (key == null)
                throw new ArgumentNullException("key");
            if (value == null && default(TValue) != null)
                throw new ArgumentNullException("value");
            if (key is string aKey) {
                if (value is TValue aValue)
                    Add(aKey, aValue);
                else
                    throw new ArgumentException("value argument has wrong type");
            }
            else
                throw new ArgumentException("key argument has wrong type");
        }
    
        bool IDictionary.Contains(object key) {
            if (key == null)
                throw new ArgumentNullException("key");
    
            if (key is string aKey)
                if (CIDict.TryGetValue(aKey, out var possibles))
                    return possibles.ContainsKey(aKey);
    
            return false;
        }
    
        void IDictionary.Remove(object key) {
            if (key == null)
                throw new ArgumentNullException("key");
    
            if (key is string aKey)
                Remove(aKey);
        }
        #endregion
    
        #region ICollection Methods
        bool ICollection.IsSynchronized => false;
        object ICollection.SyncRoot => throw new NotImplementedException();
    
        void ICollection.CopyTo(Array array, int index) {
            if (array == null)
                throw new ArgumentNullException("array");
            if (array.Rank != 1)
                throw new ArgumentException("array argument can not be multi-dimensional");
            if (array.GetLowerBound(0) != 0)
                throw new ArgumentException("array argument has non-zero lower bound");
    
            if (array is KeyValuePair<string, TValue>[] kvps) {
                CopyTo(kvps, index);
            }
            else {
                if (index < 0 || index > array.Length)
                    throw new ArgumentException("index must be non-negative and within array argument Length");
                if (array.Length - index < Count)
                    throw new ArgumentException("array argument plus index offset is too small");
                if (array is DictionaryEntry[] des) {
                    foreach (var subd in CIDict.Values)
                        foreach (var kvp in subd)
                            des[index++] = new DictionaryEntry(kvp.Key, kvp.Value);
                }
                else if (array is object[] objects) {   
                    foreach (var subd in CIDict.Values)
                        foreach (var kvp in subd)
                            objects[index++] = kvp;
                }
                else
                    throw new ArgumentException("array argument is an invalid type");
            }
        }
        #endregion
    
        #region IReadOnlyDictionary<,> Methods
        IEnumerable<string> IReadOnlyDictionary<string, TValue>.Keys => CIDict.Values.SelectMany(v => v.Keys);
        IEnumerable<TValue> IReadOnlyDictionary<string, TValue>.Values => CIDict.Values.SelectMany(v => v.Values);
        #endregion
    
        #region Case-Insensitive Properties and Methods
        public ICollection<string> KeysCI => CIDict.Keys;
        public IndexerPropertyAtCI AtCI => new IndexerPropertyAtCI(this);
    
        public bool ContainsKeyCI(string aKey) => CIDict.ContainsKey(aKey);
        public bool TryGetValueCI(string aKey, out ICollection<TValue> rtnValues) {
            if (CIDict.TryGetValue(aKey, out var theValues)) {
                rtnValues = theValues.Select(v => v.Value).ToList();
                return true;
            }
            else {
                rtnValues = default(List<TValue>);
                return false;
            }
        }
    
        public class IndexerPropertyAtCI {
            CaseDictionary<TValue> myDict;
    
            public IndexerPropertyAtCI(CaseDictionary<TValue> d) => myDict = d;
    
            public ICollection<TValue> this[string aKey] => myDict.CIDict[aKey].Select(v => v.Value).ToList();
        }
        #endregion
    
        #region IEnumerable Methods
        IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
    
        public IEnumerator<KeyValuePair<string, TValue>> GetEnumerator() {
            foreach (var subd in CIDict.Values)
                foreach (var kvp in subd)
                    yield return kvp;
        }
    
        IDictionaryEnumerator IDictionary.GetEnumerator() => new CaseDictionaryEnumerator(GetEnumerator());
    
        struct CaseDictionaryEnumerator : IDictionaryEnumerator {
            private IEnumerator<KeyValuePair<string, TValue>> en;
    
            public CaseDictionaryEnumerator(IEnumerator<KeyValuePair<string, TValue>> anEn) => en = anEn;
    
            public DictionaryEntry Entry => new DictionaryEntry(en.Current.Key, en.Current.Value);
            public object Current => Entry;
    
            public bool MoveNext() => en.MoveNext();
            public void Reset() => en.Reset();
    
            public object Key => en.Current.Key;
            public object Value => en.Current.Value;
        }
        #endregion
    }
    

    给定这个类,它可以用作:

    var d = new CaseDictionary<int>();
    d.Add("word", 1);
    d.Add("Word", 2);
    d.Add("WOrd", 3);
    d.Add("word2", 4);
    d.Add("worD2", 5);
    
    Console.WriteLine(d.ContainsKey("WOrd"));
    Console.WriteLine(d.ContainsKey("WOrd2"));
    Console.WriteLine(d.ContainsKeyCI("WOrd2"));
    Console.WriteLine(d["word2"]);
    d["word2"] = 6;
    Console.WriteLine(d["word2"]);
    
    Console.WriteLine();
    foreach (var w in d.AtCI["word2"])
        Console.WriteLine(w);
    

    输出是:

    True
    False
    True
    4
    6
    
    6
    5
    

    【讨论】:

      【解决方案4】:

      您可以使用new Dictionary&lt;string,(string CaseSensitiveKey,T Data),其中键总是小写(见下文),但是...

      A.用户更友好的搜索 string.ContainsRegex.IsMatch

      (我后来添加了这个)

      我认为您最终可能会使用string.Contains(甚至可能是Regex.IsMatch),以便您的搜索可以捕获部分匹配项。

      Regex.IsMatch

      var d = new Dictionary<string, string>() {
            { "First Last", "Some data" },
            { "Fir La", "Some data 2" } };
      
      while (true)
      {
          var term = Console.ReadLine();
      
          // Case-sensitive flag would control RegexOptions
          var results = d.Where( kvp => Regex.IsMatch(kvp.Key, term, RegexOptions.IgnoreCase)).ToList();
      
          if (results.Any())
              foreach (var kvp in results)
                  Console.WriteLine($"\t{kvp.Key}:{kvp.Value}");
          else
              Console.WriteLine("Not found");
      }
      
      fi.*la
              First Last:Some data
              Fir La:Some data 2
      fir.*t
              First Last:Some data
      

      包含

          // Case-sensitive flag would control `StrinComparison` flag.
          var results = d.Where(
            kvp => kvp.Key.ToLower().Contains(term.ToLower(), StringComparison.InvariantCultureIgnoreCase))
            .ToList();
      }
      
      Fi
      Found First Last:Some data
      Found Fir La:Some data 2
      First
      Found First Last:Some data
      Fal
      Not found
      

      B.我想要字典搜索。和快速

      您可以使用new Dictionary&lt;string,(string CaseSensitiveKey,T Data),其中键始终为小写。

      如果字典中可能有“Gerardo Grignoli”和“gerardo grignoli”,这将不起作用,但我怀疑您的情况并非如此,因为如果您要查找键,你不是在部分匹配之后。这显然只是一个假设。

      如果您想要快速解决完全匹配并处理仅因大小写而异的条目,请参阅Dictionary&lt;string, Dictionary&lt;string, TValue&gt;&gt; 的其他答案。

      public static T LowerCaseKeyWay<T>(Dictionary<string, (string CaseSensitiveKey, T Data)> d, string term, bool isCS)
                => d.TryGetValue(term.ToLower(), out var item)
                        ? !isCS
                              ? item.Data
                              : term == item.CaseSensitiveKey ? item.Data : default
                        : default;
      
      

      使用示例。

      class SO
      {
          public int Number { get; set; }
          public int Rep { get; set; }
      }
      
      
      public static void Main(string[] args)
      {
      
        var d = new Dictionary<string,(string CaseSensitiveKey,SO Data)>() {
          { "Gerardo Grignoli".ToLower(), ("Gerardo Grignoli", new SO { Number=97471, Rep=7987} )},
          { "John Wu".ToLower(), ("John Wu", new SO { Number=2791540, Rep=34973})}
        };
      
        foreach( var searchTerm in new []{ "Gerardo Grignoli",  "Gerardo Grignoli".ToLower()} )
        foreach( var isSearchCaseSensitive in new[]{true,false} ) {
            Console.WriteLine($"{searchTerm}/case-sensitive:{isSearchCaseSensitive}: {Search(d, searchTerm, isSearchCaseSensitive)?.Rep}");
        }
      
      }
      

      输出

      Gerardo Grignoli/case-sensitive:True: 7987
      Gerardo Grignoli/case-sensitive:False: 7987
      gerardo grignoli/case-sensitive:True: 
      gerardo grignoli/case-sensitive:False: 7987
      

      原始速度测试

      结果

      noOfSearches: 1000
        noOfItems: 100
          Lowercase key way:        Elapsed 4ms, count found: 1500
          Linq way                  Elapsed 57ms, count found: 1500
      noOfSearches: 1000
        noOfItems: 1000
          Lowercase key way:        Elapsed 3ms, count found: 3000
          Linq way                  Elapsed 454ms, count found: 3000
      noOfSearches: 10000
        noOfItems: 100
          Lowercase key way:        Elapsed 11ms, count found: 15000
          Linq way                  Elapsed 447ms, count found: 15000
      noOfSearches: 10000
        noOfItems: 1000
          Lowercase key way:        Elapsed 10ms, count found: 15000
          Linq way                  Elapsed 5156ms, count found: 15000
      noOfSearches: 100000
        noOfItems: 100
          Lowercase key way:        Elapsed 113ms, count found: 150000
          Linq way                  Elapsed 5059ms, count found: 150000
      noOfSearches: 100000
        noOfItems: 1000
          Lowercase key way:        Elapsed 83ms, count found: 150000
          Linq way                  Elapsed 48855ms, count found: 150000
      noOfSearches: 1000000
        noOfItems: 100
          Lowercase key way:        Elapsed 1279ms, count found: 1500000
          Linq way                  Elapsed 49558ms, count found: 1500000
      noOfSearches: 1000000
        noOfItems: 1000
          Lowercase key way:        Elapsed 961ms, count found: 1500000
      (...)
      

      测试代码(我很高兴它被撕开)

      using System;
      using System.Collections.Generic;
      using System.Diagnostics;
      using System.Linq;
      
      namespace ConsoleApp4
      {
      
          class SO
          {
              public int Number { get; set; }
      
              public int Rep { get; set; }
          }
      
        class Program
        {
            public static void Main(string[] args)
            {
              // Preload linq
              var _ = new []{"•`_´•"}.FirstOrDefault( k => k == "(O_O)" );
      
              foreach( int noOfSearches in new []{1000, 10000, 100000, 1000000} ) 
                foreach( int noOfItems in new []{100, 1000} ) 
                {
                  var d1 = new Dictionary<string, SO>();
      
                  for(int i = 0; i < noOfItems; i++) {
                    d1.Add($"Name {i}", new SO {Number = i, Rep = i *2});
                  }
      
                  var d2 = new Dictionary<string, (string CaseSensitiveKey, SO Data)>();
                  foreach (var entry in d1)
                  {
                      d2.Add(entry.Key.ToLower(), (entry.Key, entry.Value));
                  }
      
      
                  Console.WriteLine($"noOfSearches: {noOfSearches}");
                  Console.WriteLine($"  noOfItems: {noOfItems}");
      
                  Console.Write("    Lowercase key way:".PadRight(30));
                  PrimitiveSpeedTest( (term, isCS) => LowerCaseKeyWay(d2, term, isCS), noOfItems, noOfSearches);
                  Console.Write("    Linq way".PadRight(30));
                  PrimitiveSpeedTest( (term, isCS) => LinqWay(d1, term, isCS), noOfItems, noOfSearches);
                }
      
            }
      
            private static void PrimitiveSpeedTest(Func<string, bool, SO> search, int noOfItems, int noOfSearches)
            {
                var count = 0;
                Stopwatch sw = Stopwatch.StartNew();
                for (int i = 0; i < noOfSearches; i++)
                {
                  var originalTerm = $"Name {i % (noOfItems*2)}"; // Some found, some not found
                    foreach (var term in new[] { originalTerm, originalTerm.ToLower() })
                        foreach (var isCS in new[] { true, false })
                        {
                            var so = search(term, isCS);
                            if (so != null) count++;
                            //Console.WriteLine($"{term}/case-sensitive:{isCS}: {Search(d, term, isCS)?.Rep}");
                        }
      
                }
                var elapsed = sw.Elapsed;
      
                Console.WriteLine($"Elapsed {sw.ElapsedMilliseconds}ms, count found: {count} ");
            }
      
            public static SO LowerCaseKeyWay(Dictionary<string, (string CaseSensitiveKey, SO Data)> d, string term, bool isCS)
              => d.TryGetValue(term.ToLower(), out var item)
                      ? !isCS
                            ? item.Data
                            : term == item.CaseSensitiveKey ? item.Data : null
                      : null;
      
            static public T LinqWay<T>(Dictionary<string,T> source, string key, bool caseSensitive)
            {
                //Original: if (caseSensitive) return source[key];
                if(caseSensitive) return source.ContainsKey(key) ? source[key] : default;
                key = source.Keys.FirstOrDefault( k => String.Compare(key, k, StringComparison.CurrentCultureIgnoreCase) == 0);
                //Original: if (key == null) throw new KeyNotFoundException();
                if (key == null) return default;
                return source[key];
            }
        }
      }
      

      【讨论】:

        【解决方案5】:

        由于 Dictionary 对密钥进行哈希处理,因此您应该使用 Dictionary&lt;String, Dictionary&lt;String, T&gt;&gt;


        添加密钥:

        • 将给定的混合大小写键转换为全小写;
        • 获取字典到全小写键;
        • 将 添加到此字典中。

        不区分大小写的搜索:

        • 将混合大小写键转换为全小写;
        • 获取这个全小写键的字典;
        • 遍历字典中的值。

        区分大小写的搜索

        • 将混合大小写键转换为全小写;
        • 获取这个全小写键的字典;
        • 在上一步得到的字典中搜索大小写混合键。

        【讨论】:

        • 我从不喜欢必须ToLower 密钥,因为有人可能会忘记这样做。根据@NetMage 的评论,我希望外部Dictionary 有一个不区分大小写的比较器。
        • @mjwills:我同意。我也喜欢编写代码万无一失。我想一个更详细的解决方案会混淆这个想法。另一个详细的解决方案是创建一个封装所有这些功能的包装类,并允许一个布尔参数来区分大小写。
        • @mjwills:另一个很好的解决方案,也可以扩展到心理超载的细节,是修改散列算法。我们可以编写自己的哈希实现,考虑到底层结构的大小。这可以允许在忽略大小写时迭代所有相同的键。
        猜你喜欢
        • 2013-03-06
        • 2017-12-01
        • 2018-12-30
        • 1970-01-01
        • 1970-01-01
        • 2012-12-01
        • 2012-12-09
        相关资源
        最近更新 更多