Problema:
Tengo un archivo de texto de alrededor de 120.000 usuarios (cadenas) que me gustaría almacenar en una colección y luego realizar una búsqueda en esa colección.
El método de búsqueda se producirá cada vez que el usuario cambie el texto de a TextBox
y el resultado deben ser las cadenas que contienen el texto en formato TextBox
.
No tengo que cambiar la lista, simplemente extraiga los resultados y póngalos en un archivo ListBox
.
Lo que he probado hasta ahora:
Intenté con dos colecciones / contenedores diferentes, que estoy descargando las entradas de cadena de un archivo de texto externo (una vez, por supuesto):
List<string> allUsers;
HashSet<string> allUsers;
Con la siguiente consulta LINQ :
allUsers.Where(item => item.Contains(textBox_search.Text)).ToList();
Mi evento de búsqueda (se activa cuando el usuario cambia el texto de búsqueda):
private void textBox_search_TextChanged(object sender, EventArgs e)
{
if (textBox_search.Text.Length > 2)
{
listBox_choices.DataSource = allUsers.Where(item => item.Contains(textBox_search.Text)).ToList();
}
else
{
listBox_choices.DataSource = null;
}
}
Resultados:
Ambos me dieron un tiempo de respuesta pobre (alrededor de 1-3 segundos entre cada pulsación de tecla).
Pregunta:
¿Dónde crees que está mi cuello de botella? ¿La colección que he usado? ¿El método de búsqueda? ¿Ambos?
¿Cómo puedo obtener un mejor rendimiento y una funcionalidad más fluida?
fuente
HashSet<T>
no te ayudará aquí, porque estás buscando la parte de la cadena.Respuestas:
Podría considerar realizar la tarea de filtrado en un hilo en segundo plano que invocaría un método de devolución de llamada cuando esté listo, o simplemente reiniciar el filtrado si se cambia la entrada.
La idea general es poder usarlo así:
public partial class YourForm : Form { private readonly BackgroundWordFilter _filter; public YourForm() { InitializeComponent(); // setup the background worker to return no more than 10 items, // and to set ListBox.DataSource when results are ready _filter = new BackgroundWordFilter ( items: GetDictionaryItems(), maxItemsToMatch: 10, callback: results => this.Invoke(new Action(() => listBox_choices.DataSource = results)) ); } private void textBox_search_TextChanged(object sender, EventArgs e) { // this will update the background worker's "current entry" _filter.SetCurrentEntry(textBox_search.Text); } }
Un boceto aproximado sería algo como:
public class BackgroundWordFilter : IDisposable { private readonly List<string> _items; private readonly AutoResetEvent _signal = new AutoResetEvent(false); private readonly Thread _workerThread; private readonly int _maxItemsToMatch; private readonly Action<List<string>> _callback; private volatile bool _shouldRun = true; private volatile string _currentEntry = null; public BackgroundWordFilter( List<string> items, int maxItemsToMatch, Action<List<string>> callback) { _items = items; _callback = callback; _maxItemsToMatch = maxItemsToMatch; // start the long-lived backgroud thread _workerThread = new Thread(WorkerLoop) { IsBackground = true, Priority = ThreadPriority.BelowNormal }; _workerThread.Start(); } public void SetCurrentEntry(string currentEntry) { // set the current entry and signal the worker thread _currentEntry = currentEntry; _signal.Set(); } void WorkerLoop() { while (_shouldRun) { // wait here until there is a new entry _signal.WaitOne(); if (!_shouldRun) return; var entry = _currentEntry; var results = new List<string>(); // if there is nothing to process, // return an empty list if (string.IsNullOrEmpty(entry)) { _callback(results); continue; } // do the search in a for-loop to // allow early termination when current entry // is changed on a different thread foreach (var i in _items) { // if matched, add to the list of results if (i.Contains(entry)) results.Add(i); // check if the current entry was updated in the meantime, // or we found enough items if (entry != _currentEntry || results.Count >= _maxItemsToMatch) break; } if (entry == _currentEntry) _callback(results); } } public void Dispose() { // we are using AutoResetEvent and a background thread // and therefore must dispose it explicitly Dispose(true); } private void Dispose(bool disposing) { if (!disposing) return; // shutdown the thread if (_workerThread.IsAlive) { _shouldRun = false; _currentEntry = null; _signal.Set(); _workerThread.Join(); } // if targetting .NET 3.5 or older, we have to // use the explicit IDisposable implementation (_signal as IDisposable).Dispose(); } }
Además, debería eliminar la
_filter
instancia cuando se elimine el padreForm
. Esto significa que debe abrir y editar suForm
s'Dispose
método (dentro delYourForm.Designer.cs
archivo) para buscar algo como:// inside "xxxxxx.Designer.cs" protected override void Dispose(bool disposing) { if (disposing) { if (_filter != null) _filter.Dispose(); // this part is added by Visual Studio designer if (components != null) components.Dispose(); } base.Dispose(disposing); }
En mi máquina, funciona bastante rápido, por lo que debe probar y perfilar esto antes de buscar una solución más compleja.
Dicho esto, una "solución más compleja" posiblemente sería almacenar el último par de resultados en un diccionario, y luego filtrarlos únicamente si resulta que la nueva entrada difiere solo en el primero del último carácter.
fuente
_signal.Dispose();
compilar (error sobre el nivel de protección)._signal.Dispose()
¿Está fuera de laBackgroundWordFilter
clase?using
bloque o una llamadaWaitHandle.Close()
.Close()
lugar, puede llamar , lo que a su vez llama.Dispose()
.Hice algunas pruebas, y buscar en una lista de 120.000 elementos y completar una nueva lista con las entradas lleva una cantidad de tiempo insignificante (aproximadamente 1/50 de segundo, incluso si todas las cadenas coinciden).
Por lo tanto, el problema que está viendo debe provenir del llenado de la fuente de datos, aquí:
Sospecho que simplemente está poniendo demasiados elementos en el cuadro de lista.
Quizás deberías intentar limitarlo a las primeras 20 entradas, así:
listBox_choices.DataSource = allUsers.Where(item => item.Contains(textBox_search.Text)) .Take(20).ToList();
También tenga en cuenta (como otros han señalado) que está accediendo a la
TextBox.Text
propiedad para cada elemento enallUsers
. Esto se puede solucionar fácilmente de la siguiente manera:string target = textBox_search.Text; listBox_choices.DataSource = allUsers.Where(item => item.Contains(target)) .Take(20).ToList();
Sin embargo, cronometré el tiempo que se tarda en acceder
TextBox.Text
500.000 veces y solo tomó 0,7 segundos, mucho menos que los 1-3 segundos mencionados en el OP. Aún así, esta es una optimización que vale la pena.fuente
textBox_search.Text
Contribuye una cantidad mensurable al tiempo? Obtener laText
propiedad en un cuadro de texto una vez para cada una de las 120k cadenas probablemente envíe 120k mensajes a la ventana de control de edición.Usar árbol de sufijo como índice. O más bien, simplemente cree un diccionario ordenado que asocie cada sufijo de cada nombre con la lista de nombres correspondientes.
Para entrada:
La estructura se vería así:
Algoritmo de búsqueda
Suponga la entrada del usuario "sujetador".
Estos árboles están diseñados para una búsqueda rápida de subcadenas. Su rendimiento está cerca de O (log n). Creo que este enfoque funcionará lo suficientemente rápido como para ser utilizado directamente por el hilo de la GUI. Además, funcionará más rápido que la solución con subprocesos debido a la ausencia de sobrecarga de sincronización.
fuente
Necesita un motor de búsqueda de texto (como Lucene.Net ) o una base de datos (puede considerar uno integrado como SQL CE , SQLite , etc.). En otras palabras, necesita una búsqueda indexada. La búsqueda basada en hash no se aplica aquí, porque busca subcadena, mientras que la búsqueda basada en hash es adecuada para buscar el valor exacto.
De lo contrario, será una búsqueda iterativa que recorrerá la colección.
fuente
string.Contains
. Es decir. buscarba
enbcaaaabaa
resultará en una lista (indexado) saltar. Seb
considera el primero , pero no coincide porque el siguiente es unc
, por lo que pasará al siguienteb
.También puede ser útil tener un evento del tipo "antirrebote". Esto se diferencia de la limitación en que espera un período de tiempo (por ejemplo, 200 ms) a que finalicen los cambios antes de activar el evento.
Ver Debounce and Throttle: una explicación visual para obtener más información sobre el antirrebote. Aprecio que este artículo se centre en JavaScript, en lugar de C #, pero se aplica el principio.
La ventaja de esto es que no busca cuando todavía está ingresando su consulta. Entonces debería dejar de intentar realizar dos búsquedas a la vez.
fuente
Ejecute la búsqueda en otro hilo y muestre alguna animación de carga o una barra de progreso mientras ese hilo se está ejecutando.
También puede intentar paralelizar la consulta LINQ .
var queryResults = strings.AsParallel().Where(item => item.Contains("1")).ToList();
Aquí hay un punto de referencia que demuestra las ventajas de rendimiento de AsParallel ():
{ IEnumerable<string> queryResults; bool useParallel = true; var strings = new List<string>(); for (int i = 0; i < 2500000; i++) strings.Add(i.ToString()); var stp = new Stopwatch(); stp.Start(); if (useParallel) queryResults = strings.AsParallel().Where(item => item.Contains("1")).ToList(); else queryResults = strings.Where(item => item.Contains("1")).ToList(); stp.Stop(); Console.WriteLine("useParallel: {0}\r\nTime Elapsed: {1}", useParallel, stp.ElapsedMilliseconds); }
fuente
String.Contains
no es caro. msdn.microsoft.com/en-us/library/dd997399.aspxActualizar:
Hice algunos perfiles.
(Actualización 3)
La prueba inicial de 2.500.000 registros me llevó 20.000ms.
El culpable número uno es la llamada al
textBox_search.Text
interiorContains
. Esto hace una llamada para cada elemento al costosoget_WindowText
método del cuadro de texto. Simplemente cambiando el código a:var text = textBox_search.Text; listBox_choices.DataSource = allUsers.Where(item => item.Contains(text)).ToList();
redujo el tiempo de ejecución a 1.858ms .
Actualización 2:
Los otros dos cuellos de botella importantes son ahora la llamada a
string.Contains
(aproximadamente el 45% del tiempo de ejecución) y la actualización de los elementos del cuadro de lista enset_Datasource
(30%).Podríamos hacer un intercambio entre la velocidad y el uso de la memoria creando un árbol de sufijos como ha sugerido Basilevs para reducir el número de comparaciones necesarias y empujar un poco de tiempo de procesamiento desde la búsqueda después de presionar una tecla hasta la carga de los nombres del archivo que puede ser preferible para el usuario.
Para aumentar el rendimiento de la carga de elementos en el cuadro de lista, sugeriría cargar solo los primeros elementos e indicar al usuario que hay más elementos disponibles. De esta manera, le das una retroalimentación al usuario de que hay resultados disponibles para que pueda refinar su búsqueda ingresando más letras o cargar la lista completa con solo presionar un botón.
Utilizando
BeginUpdate
yEndUpdate
sin cambios en el tiempo de ejecución deset_Datasource
.Como otros han señalado aquí, la consulta LINQ en sí se ejecuta bastante rápido. Creo que su cuello de botella es la actualización del cuadro de lista en sí. Puedes probar algo como:if (textBox_search.Text.Length > 2) { listBox_choices.BeginUpdate(); listBox_choices.DataSource = allUsers.Where(item => item.Contains(textBox_search.Text)).ToList(); listBox_choices.EndUpdate(); }
Espero que esto ayude.fuente
BeginUpdate
ya queEndUpdate
están destinados a usarse al agregar elementos individualmente o al usarlosAddRange()
.DataSource
se implemente la propiedad. Podría valer la pena intentarlo.Suponiendo que solo coincide con prefijos, la estructura de datos que está buscando se llama trie , también conocida como "árbol de prefijos". El
IEnumerable.Where
método que está utilizando ahora tendrá que recorrer todos los elementos de su diccionario en cada acceso.Este hilo muestra cómo crear un trie en C #.
fuente
El control WinForms ListBox realmente es su enemigo aquí. La carga de los registros será lenta y ScrollBar luchará contra usted para mostrar los 120,000 registros.
Intente usar un DataGridView antiguo derivado de datos en un DataTable con una sola columna [UserName] para almacenar sus datos:
private DataTable dt; public Form1() { InitializeComponent(); dt = new DataTable(); dt.Columns.Add("UserName"); for (int i = 0; i < 120000; ++i){ DataRow dr = dt.NewRow(); dr[0] = "user" + i.ToString(); dt.Rows.Add(dr); } dgv.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill; dgv.AllowUserToAddRows = false; dgv.AllowUserToDeleteRows = false; dgv.RowHeadersVisible = false; dgv.DataSource = dt; }
Luego use un DataView en el evento TextChanged de su TextBox para filtrar los datos:
private void textBox1_TextChanged(object sender, EventArgs e) { DataView dv = new DataView(dt); dv.RowFilter = string.Format("[UserName] LIKE '%{0}%'", textBox1.Text); dgv.DataSource = dv; }
fuente
En primer lugar me gustaría cambiar la forma
ListControl
ve el origen de datos, que está convirtiendo resultadoIEnumerable<string>
aList<string>
. Especialmente cuando acaba de escribir pocos caracteres, esto puede ser ineficaz (e innecesario). No haga copias extensivas de sus datos ..Where()
resultado en una colección que implementa solo lo que se requiere deIList
(búsqueda). Esto le evitará crear una nueva lista grande para cada carácter que se escriba.El segundo paso es no buscar en la lista grande cuando una pequeña es suficiente. Cuando el usuario comenzó a escribir "ab" y agrega "c", entonces no necesita buscar en la lista grande, la búsqueda en la lista filtrada es suficiente (y más rápida). Refinar busqueda cada vez que sea posible, no realice una búsqueda completa cada vez.
El tercer paso puede ser más difícil: mantenga los datos organizados para que se puedan buscar rápidamente . Ahora tienes que cambiar la estructura que usas para almacenar tus datos. imagina un árbol como este:
Esto puede implementarse simplemente con una matriz (si está trabajando con nombres ANSI, de lo contrario, sería mejor un diccionario). Cree la lista de esta manera (con fines ilustrativos, coincide con el comienzo de la cadena):
var dictionary = new Dictionary<char, List<string>>(); foreach (var user in users) { char letter = user[0]; if (dictionary.Contains(letter)) dictionary[letter].Add(user); else { var newList = new List<string>(); newList.Add(user); dictionary.Add(letter, newList); } }
La búsqueda se realizará utilizando el primer carácter:
char letter = textBox_search.Text[0]; if (dictionary.Contains(letter)) { listBox_choices.DataSource = new MyListWrapper(dictionary[letter].Where(x => x.Contains(textBox_search.Text))); }
Tenga en cuenta que utilicé
MyListWrapper()
como se sugirió en el primer paso (pero omití la segunda sugerencia por brevedad, si elige el tamaño correcto para la clave del diccionario, puede mantener cada lista corta y rápida para, tal vez, evitar cualquier otra cosa). Además, tenga en cuenta que puede intentar utilizar los dos primeros caracteres para su diccionario (más listas y más corto). Si extiende esto, tendrá un árbol (pero no creo que tenga una cantidad tan grande de elementos).Hay muchos algoritmos diferentes para la búsqueda de cadenas (con estructuras de datos relacionadas), solo por mencionar algunos:
Pocas palabras sobre la búsqueda paralela. Es posible, pero rara vez es trivial porque la sobrecarga para hacerlo paralelo puede ser fácilmente mucho mayor que la búsqueda en sí. No realizaría la búsqueda en paralelo (la partición y la sincronización pronto se volverán demasiado expansivas y quizás complejas) pero movería la búsqueda a un hilo separado . Si el hilo principal no está ocupado, sus usuarios no sentirán ningún retraso mientras escriben (no notarán si la lista aparecerá después de 200 ms, pero se sentirán incómodos si tienen que esperar 50 ms después de escribir) . Por supuesto, la búsqueda en sí debe ser lo suficientemente rápida; en este caso, no utiliza subprocesos para acelerar la búsqueda sino para mantener la interfaz de usuario receptiva . consulta, no se bloqueará la interfaz de usuario, pero si su consulta fue lenta, seguirá siendo lenta en un hilo separado (además, también debe manejar múltiples solicitudes secuenciales).
fuente
Contains
, noStartsWith
). En una nota al margen, generalmente es mejor usar elContainsKey
método genérico al buscar una clave para evitar el boxeo, e incluso mejor usarloTryGetValue
para evitar dos búsquedas.Puede intentar usar PLINQ (Parallel LINQ). Aunque esto no garantiza un aumento de velocidad, debe averiguarlo mediante prueba y error.
fuente
Dudo que puedas hacerlo más rápido, pero seguro que deberías:
a) Utilice el método de extensión AsParallel LINQ
a) Use algún tipo de temporizador para retrasar el filtrado
b) Ponga un método de filtrado en otro hilo
Guarde algún tipo de en
string previousTextBoxValue
algún lugar. Haga un temporizador con un retraso de 1000 ms, que se active buscando en el tic sipreviousTextBoxValue
es el mismo que sutextbox.Text
valor. Si no es así, reasignepreviousTextBoxValue
al valor actual y reinicie el temporizador. Configure el inicio del temporizador para el evento de cambio de cuadro de texto y hará que su aplicación sea más fluida. Filtrar 120.000 registros en 1-3 segundos está bien, pero su interfaz de usuario debe seguir respondiendo.fuente
También puede intentar usar la función BindingSource.Filter . Lo he usado y funciona como un encanto para filtrar de un montón de registros, cada vez que actualizo esta propiedad con el texto que se busca. Otra opción sería utilizar AutoCompleteSource para el control TextBox.
¡Espero eso ayude!
fuente
Intentaría ordenar la colección, buscar para que coincida solo con la parte inicial y limitar la búsqueda por algún número.
así sucesivamente inicialización
y buscar
Quizás puedas agregar algo de caché.
fuente
Utilice Paralelo
LINQ
.PLINQ
es una implementación paralela de LINQ to Objects. PLINQ implementa el conjunto completo de operadores de consulta estándar LINQ como métodos de extensión para el espacio de nombres T: System.Linq y tiene operadores adicionales para operaciones paralelas. PLINQ combina la simplicidad y legibilidad de la sintaxis LINQ con el poder de la programación paralela. Al igual que el código que se dirige a la biblioteca paralela de tareas, las consultas PLINQ se escalan en el grado de simultaneidad según las capacidades de la computadora host.Introducción a PLINQ
Entendiendo Speedup en PLINQ
También puedes usar Lucene.Net
fuente
Según lo que he visto estoy de acuerdo con el hecho de ordenar la lista.
Sin embargo, ordenar cuando se construya la lista será muy lento, ordenar al construir, tendrá un mejor tiempo de ejecución.
De lo contrario, si no necesita mostrar la lista o mantener el orden, use un mapa de hash.
El mapa de hash aplicará un hash a su cadena y buscará el desplazamiento exacto. Creo que debería ser más rápido.
fuente
Intente usar el método BinarySearch, debería funcionar más rápido que el método Contiene.
Contiene será un O (n) BinarySearch es un O (lg (n))
Creo que la colección ordenada debería funcionar más rápido en la búsqueda y más lento al agregar nuevos elementos, pero según tengo entendido, solo tiene un problema de rendimiento de búsqueda.
fuente